Repository: transitive-bullshit/agentic Branch: main Commit: beffa8ecfaff Files: 1049 Total size: 24.1 MB Directory structure: gitextract_8yb8dgx_/ ├── .cursor/ │ └── rules/ │ └── general.mdc ├── .editorconfig ├── .github/ │ ├── funding.yml │ └── workflows/ │ ├── main.yml │ └── release.yml ├── .gitignore ├── .npmrc ├── .prettierignore ├── .vscode/ │ └── launch.json ├── CLAUDE.md ├── Tiltfile ├── apps/ │ ├── api/ │ │ ├── drizzle.config.ts │ │ ├── package.json │ │ ├── readme.md │ │ ├── src/ │ │ │ ├── api-v1/ │ │ │ │ ├── auth/ │ │ │ │ │ ├── github-callback.ts │ │ │ │ │ ├── github-exchange.ts │ │ │ │ │ ├── github-init.ts │ │ │ │ │ ├── schemas.ts │ │ │ │ │ ├── sign-in-with-password.ts │ │ │ │ │ ├── sign-up-with-password.ts │ │ │ │ │ └── utils.ts │ │ │ │ ├── consumers/ │ │ │ │ │ ├── admin-activate-consumer.ts │ │ │ │ │ ├── admin-get-consumer-by-api-key.ts │ │ │ │ │ ├── create-billing-portal-session.ts │ │ │ │ │ ├── create-consumer-billing-portal-session.ts │ │ │ │ │ ├── create-consumer-checkout-session.ts │ │ │ │ │ ├── create-consumer.ts │ │ │ │ │ ├── get-consumer-by-project-identifier.ts │ │ │ │ │ ├── get-consumer.ts │ │ │ │ │ ├── list-consumers.ts │ │ │ │ │ ├── list-project-consumers.ts │ │ │ │ │ ├── refresh-consumer-api-key.ts │ │ │ │ │ ├── schemas.ts │ │ │ │ │ ├── update-consumer.ts │ │ │ │ │ └── utils.ts │ │ │ │ ├── deployments/ │ │ │ │ │ ├── admin-get-deployment-by-identifier.ts │ │ │ │ │ ├── create-deployment.ts │ │ │ │ │ ├── get-deployment-by-identifier.ts │ │ │ │ │ ├── get-deployment.ts │ │ │ │ │ ├── get-public-deployment-by-identifier.ts │ │ │ │ │ ├── list-deployments.ts │ │ │ │ │ ├── publish-deployment.ts │ │ │ │ │ ├── schemas.ts │ │ │ │ │ └── update-deployment.ts │ │ │ │ ├── health-check.ts │ │ │ │ ├── index.ts │ │ │ │ ├── projects/ │ │ │ │ │ ├── create-project.ts │ │ │ │ │ ├── get-project-by-identifier.ts │ │ │ │ │ ├── get-project.ts │ │ │ │ │ ├── get-public-project-by-identifier.ts │ │ │ │ │ ├── get-public-project.ts │ │ │ │ │ ├── list-projects.ts │ │ │ │ │ ├── list-public-projects.ts │ │ │ │ │ ├── schemas.ts │ │ │ │ │ └── update-project.ts │ │ │ │ ├── storage/ │ │ │ │ │ └── get-signed-storage-upload-url.ts │ │ │ │ ├── teams/ │ │ │ │ │ ├── create-team.ts │ │ │ │ │ ├── delete-team.ts │ │ │ │ │ ├── get-team.ts │ │ │ │ │ ├── list-teams.ts │ │ │ │ │ ├── members/ │ │ │ │ │ │ ├── create-team-member.ts │ │ │ │ │ │ ├── delete-team-member.ts │ │ │ │ │ │ ├── schemas.ts │ │ │ │ │ │ └── update-team-member.ts │ │ │ │ │ ├── schemas.ts │ │ │ │ │ └── update-team.ts │ │ │ │ ├── users/ │ │ │ │ │ ├── get-user.ts │ │ │ │ │ ├── schemas.ts │ │ │ │ │ └── update-user.ts │ │ │ │ └── webhooks/ │ │ │ │ └── stripe-webhook.ts │ │ │ ├── db/ │ │ │ │ ├── index.ts │ │ │ │ ├── schema/ │ │ │ │ │ ├── account.ts │ │ │ │ │ ├── auth-data.ts │ │ │ │ │ ├── common.test.ts │ │ │ │ │ ├── common.ts │ │ │ │ │ ├── consumer.ts │ │ │ │ │ ├── deployment.ts │ │ │ │ │ ├── index.ts │ │ │ │ │ ├── log-entry.ts │ │ │ │ │ ├── project.ts │ │ │ │ │ ├── team-member.ts │ │ │ │ │ ├── team.ts │ │ │ │ │ └── user.ts │ │ │ │ ├── schemas.ts │ │ │ │ ├── types.test.ts │ │ │ │ ├── types.ts │ │ │ │ └── utils.ts │ │ │ ├── lib/ │ │ │ │ ├── __snapshots__/ │ │ │ │ │ └── storage.test.ts.snap │ │ │ │ ├── acl-admin.ts │ │ │ │ ├── acl-public-project.ts │ │ │ │ ├── acl-team-admin.ts │ │ │ │ ├── acl-team-member.ts │ │ │ │ ├── acl.ts │ │ │ │ ├── auth/ │ │ │ │ │ ├── auth-storage.ts │ │ │ │ │ ├── create-auth-token.ts │ │ │ │ │ ├── drizzle-auth-storage.ts │ │ │ │ │ └── upsert-or-link-user-account.ts │ │ │ │ ├── billing/ │ │ │ │ │ ├── create-stripe-checkout-session.ts │ │ │ │ │ ├── upsert-stripe-connect-customer.ts │ │ │ │ │ ├── upsert-stripe-customer.ts │ │ │ │ │ ├── upsert-stripe-pricing-resources.ts │ │ │ │ │ └── upsert-stripe-subscription.ts │ │ │ │ ├── cache-control.ts │ │ │ │ ├── consumers/ │ │ │ │ │ ├── upsert-consumer-stripe-checkout.ts │ │ │ │ │ ├── upsert-consumer.ts │ │ │ │ │ └── utils.ts │ │ │ │ ├── create-avatar.ts │ │ │ │ ├── create-consumer-api-key.ts │ │ │ │ ├── deployments/ │ │ │ │ │ ├── get-deployment-by-id.ts │ │ │ │ │ ├── normalize-deployment-version.ts │ │ │ │ │ ├── publish-deployment.ts │ │ │ │ │ └── try-get-deployment-by-identifier.ts │ │ │ │ ├── ensure-auth-user.ts │ │ │ │ ├── ensure-unique-namespace.ts │ │ │ │ ├── env.ts │ │ │ │ ├── exit-hooks.ts │ │ │ │ ├── external/ │ │ │ │ │ ├── github.ts │ │ │ │ │ ├── resend.ts │ │ │ │ │ ├── sentry.ts │ │ │ │ │ └── stripe.ts │ │ │ │ ├── middleware/ │ │ │ │ │ ├── authenticate.ts │ │ │ │ │ ├── index.ts │ │ │ │ │ ├── me.ts │ │ │ │ │ └── team.ts │ │ │ │ ├── openapi-utils.ts │ │ │ │ ├── projects/ │ │ │ │ │ └── try-get-project-by-identifier.ts │ │ │ │ ├── storage.test.ts │ │ │ │ ├── storage.ts │ │ │ │ ├── temp │ │ │ │ ├── types.ts │ │ │ │ └── utils.ts │ │ │ ├── oauth-redirect.ts │ │ │ ├── reset.d.ts │ │ │ └── server.ts │ │ ├── tsconfig.json │ │ ├── tsup.config.ts │ │ └── vitest.config.ts │ ├── e2e/ │ │ ├── bin/ │ │ │ ├── deploy-fixtures.ts │ │ │ ├── publish-fixtures.ts │ │ │ └── seed-db.ts │ │ ├── package.json │ │ ├── src/ │ │ │ ├── __snapshots__/ │ │ │ │ ├── http-e2e.test.ts.snap │ │ │ │ └── mcp-e2e.test.ts.snap │ │ │ ├── agentic-examples.ts │ │ │ ├── deploy-projects.ts │ │ │ ├── dev-client.ts │ │ │ ├── dev-fixtures.ts │ │ │ ├── env.ts │ │ │ ├── http-e2e.test.ts │ │ │ ├── http-fixtures.ts │ │ │ ├── mcp-e2e.test.ts │ │ │ ├── mcp-fixtures.ts │ │ │ └── publish-deployments.ts │ │ ├── tsconfig.json │ │ └── vitest.config.ts │ ├── gateway/ │ │ ├── .dev.vars.example │ │ ├── package.json │ │ ├── src/ │ │ │ ├── app.ts │ │ │ ├── lib/ │ │ │ │ ├── __snapshots__/ │ │ │ │ │ └── utils.test.ts.snap │ │ │ │ ├── agentic-client.ts │ │ │ │ ├── cf-validate-json-schema.ts │ │ │ │ ├── create-http-request-for-openapi-operation.ts │ │ │ │ ├── create-http-response-from-mcp-tool-call-response.ts │ │ │ │ ├── durable-mcp-client.ts │ │ │ │ ├── durable-mcp-server.ts │ │ │ │ ├── env.ts │ │ │ │ ├── external/ │ │ │ │ │ └── stripe.ts │ │ │ │ ├── fetch-cache.ts │ │ │ │ ├── get-admin-consumer.ts │ │ │ │ ├── get-admin-deployment.ts │ │ │ │ ├── get-request-cache-key.ts │ │ │ │ ├── get-tool-args-from-request.ts │ │ │ │ ├── get-tool.ts │ │ │ │ ├── handle-mcp-tool-call-error.ts │ │ │ │ ├── normalize-url.test.ts │ │ │ │ ├── normalize-url.ts │ │ │ │ ├── rate-limits/ │ │ │ │ │ ├── durable-rate-limiter.ts │ │ │ │ │ └── enforce-rate-limit.ts │ │ │ │ ├── record-tool-call-usage.ts │ │ │ │ ├── reset.d.ts │ │ │ │ ├── resolve-edge-request.ts │ │ │ │ ├── resolve-http-edge-request.ts │ │ │ │ ├── resolve-mcp-edge-request.ts │ │ │ │ ├── resolve-origin-tool-call.ts │ │ │ │ ├── temp │ │ │ │ ├── temp-mcp │ │ │ │ ├── transform-http-response-to-mcp-tool-call-response.ts │ │ │ │ ├── types.ts │ │ │ │ ├── update-origin-request.ts │ │ │ │ ├── utils.test.ts │ │ │ │ └── utils.ts │ │ │ └── worker.ts │ │ ├── tsconfig.json │ │ ├── vitest.config.ts │ │ └── wrangler.jsonc │ └── web/ │ ├── components.json │ ├── next.config.ts │ ├── package.json │ ├── postcss.config.mjs │ ├── public/ │ │ ├── adamsbridge.hdr │ │ └── schema.json │ ├── readme.md │ ├── src/ │ │ ├── app/ │ │ │ ├── about/ │ │ │ │ └── page.tsx │ │ │ ├── app/ │ │ │ │ ├── app-dashboard.tsx │ │ │ │ ├── consumers/ │ │ │ │ │ ├── [consumerId]/ │ │ │ │ │ │ ├── app-consumer-index.tsx │ │ │ │ │ │ └── page.tsx │ │ │ │ │ ├── app-consumers-index.tsx │ │ │ │ │ └── page.tsx │ │ │ │ ├── page.tsx │ │ │ │ ├── projects/ │ │ │ │ │ ├── [namespace]/ │ │ │ │ │ │ └── [project-slug]/ │ │ │ │ │ │ ├── app-project-index.tsx │ │ │ │ │ │ └── page.tsx │ │ │ │ │ ├── app-projects-index.tsx │ │ │ │ │ └── page.tsx │ │ │ │ └── temp-testing │ │ │ ├── auth/ │ │ │ │ └── [provider]/ │ │ │ │ └── success/ │ │ │ │ ├── oauth-success-callback.tsx │ │ │ │ └── page.tsx │ │ │ ├── contact/ │ │ │ │ └── page.tsx │ │ │ ├── globals.css │ │ │ ├── layout.tsx │ │ │ ├── login/ │ │ │ │ ├── login-form.tsx │ │ │ │ └── page.tsx │ │ │ ├── logout/ │ │ │ │ └── page.tsx │ │ │ ├── marketplace/ │ │ │ │ ├── marketplace-index.tsx │ │ │ │ ├── page.tsx │ │ │ │ └── projects/ │ │ │ │ └── [namespace]/ │ │ │ │ └── [project-slug]/ │ │ │ │ ├── marketplace-nav.tsx │ │ │ │ ├── marketplace-public-project-detail.tsx │ │ │ │ ├── page.tsx │ │ │ │ └── utils.ts │ │ │ ├── not-found.tsx │ │ │ ├── page.tsx │ │ │ ├── pricing/ │ │ │ │ └── page.tsx │ │ │ ├── privacy/ │ │ │ │ └── page.tsx │ │ │ ├── providers.tsx │ │ │ ├── publishing/ │ │ │ │ └── page.tsx │ │ │ ├── signup/ │ │ │ │ ├── page.tsx │ │ │ │ └── signup-form.tsx │ │ │ └── terms/ │ │ │ └── page.tsx │ │ ├── components/ │ │ │ ├── active-link.tsx │ │ │ ├── agentic-provider.tsx │ │ │ ├── app-consumers-list.tsx │ │ │ ├── app-projects-list.tsx │ │ │ ├── bootstrap.tsx │ │ │ ├── code-block/ │ │ │ │ ├── highlight.ts │ │ │ │ └── index.tsx │ │ │ ├── confetti.tsx │ │ │ ├── dark-mode-toggle.tsx │ │ │ ├── demand-side-cta.tsx │ │ │ ├── dots-section.tsx │ │ │ ├── example-agentic-configs.tsx │ │ │ ├── example-usage-section.tsx │ │ │ ├── example-usage.tsx │ │ │ ├── feature.tsx │ │ │ ├── footer/ │ │ │ │ ├── dynamic.tsx │ │ │ │ └── index.tsx │ │ │ ├── github-star-counter.tsx │ │ │ ├── grid-pattern.tsx │ │ │ ├── header/ │ │ │ │ ├── authenticated-header.tsx │ │ │ │ ├── index.tsx │ │ │ │ ├── styles.module.css │ │ │ │ └── unauthenticated-header.tsx │ │ │ ├── hero-button/ │ │ │ │ ├── index.tsx │ │ │ │ └── styles.module.css │ │ │ ├── hero-simulation-2.tsx │ │ │ ├── hero-simulation.tsx │ │ │ ├── loading-indicator/ │ │ │ │ ├── index.tsx │ │ │ │ ├── loading-dark.json │ │ │ │ ├── loading-light.json │ │ │ │ └── styles.module.css │ │ │ ├── markdown/ │ │ │ │ ├── index.tsx │ │ │ │ ├── ssr-markdown.tsx │ │ │ │ └── styles.module.css │ │ │ ├── mcp-gateway-features.tsx │ │ │ ├── mcp-marketplace-features.tsx │ │ │ ├── page-container.tsx │ │ │ ├── posthog-provider.tsx │ │ │ ├── project-pricing-plans/ │ │ │ │ ├── index.tsx │ │ │ │ └── project-pricing-plan.tsx │ │ │ ├── public-project.tsx │ │ │ ├── supply-side-cta.tsx │ │ │ ├── theme-provider.tsx │ │ │ └── ui/ │ │ │ ├── avatar.tsx │ │ │ ├── breadcrumb.tsx │ │ │ ├── button.tsx │ │ │ ├── collapsible.tsx │ │ │ ├── dropdown-menu.tsx │ │ │ ├── input.tsx │ │ │ ├── label.tsx │ │ │ ├── tabs.tsx │ │ │ └── tooltip.tsx │ │ ├── icons/ │ │ │ ├── github.tsx │ │ │ ├── twitter.tsx │ │ │ └── typescript.tsx │ │ ├── lib/ │ │ │ ├── auth-copy.ts │ │ │ ├── bootstrap.ts │ │ │ ├── config.ts │ │ │ ├── default-agentic-api-client.ts │ │ │ ├── developer-config.ts │ │ │ ├── global-api.ts │ │ │ ├── notifications.ts │ │ │ ├── query-client.ts │ │ │ ├── utils.test.ts │ │ │ └── utils.ts │ │ └── reset.d.ts │ └── tsconfig.json ├── contributing.md ├── docs/ │ ├── contact.mdx │ ├── docs.json │ ├── index.mdx │ ├── inject.js │ ├── marketplace/ │ │ ├── index.mdx │ │ ├── mcp-clients/ │ │ │ ├── claude-code.mdx │ │ │ ├── claude-desktop.mdx │ │ │ ├── cline.mdx │ │ │ ├── cursor.mdx │ │ │ ├── raycast.mdx │ │ │ ├── trae.mdx │ │ │ ├── vscode.mdx │ │ │ ├── warp.mdx │ │ │ └── windsurf.mdx │ │ └── ts-sdks/ │ │ ├── ai-sdk.mdx │ │ ├── genkit.mdx │ │ ├── langchain.mdx │ │ ├── llamaindex.mdx │ │ ├── mastra.mdx │ │ ├── openai-chat.mdx │ │ └── openai-responses.mdx │ ├── package.json │ └── publishing/ │ ├── config/ │ │ ├── auth.mdx │ │ ├── caching.mdx │ │ ├── examples.mdx │ │ ├── index.mdx │ │ ├── pricing.mdx │ │ ├── rate-limits.mdx │ │ └── tool-config.mdx │ ├── guides/ │ │ ├── existing-mcp-server.mdx │ │ ├── existing-openapi-service.mdx │ │ ├── py-fastmcp.mdx │ │ ├── ts-fastmcp.mdx │ │ ├── ts-mcp-hono.mdx │ │ ├── ts-modelfetch.mdx │ │ ├── ts-openapi-hono.mdx │ │ └── ts-xmcp.mdx │ ├── index.mdx │ ├── origin/ │ │ ├── index.mdx │ │ ├── metadata.mdx │ │ └── security.mdx │ └── quickstart.mdx ├── eslint.config.js ├── examples/ │ ├── mcp-servers/ │ │ ├── context7/ │ │ │ ├── agentic.config.ts │ │ │ ├── package.json │ │ │ └── tsconfig.json │ │ ├── github/ │ │ │ ├── agentic.config.ts │ │ │ ├── package.json │ │ │ └── tsconfig.json │ │ ├── search/ │ │ │ ├── .dev.vars.example │ │ │ ├── agentic.config.ts │ │ │ ├── package.json │ │ │ ├── src/ │ │ │ │ ├── env.ts │ │ │ │ └── worker.ts │ │ │ ├── tsconfig.json │ │ │ └── wrangler.jsonc │ │ └── xmcp/ │ │ ├── .gitignore │ │ ├── agentic.config.ts │ │ ├── package.json │ │ ├── src/ │ │ │ └── tools/ │ │ │ └── greet.ts │ │ ├── tsconfig.json │ │ ├── vercel.json │ │ ├── xmcp-env.d.ts │ │ └── xmcp.config.ts │ └── ts-sdks/ │ ├── ai-sdk/ │ │ ├── bin/ │ │ │ ├── mcp-filesystem.ts │ │ │ ├── weather-experimental-active-tools.ts │ │ │ └── weather.ts │ │ ├── package.json │ │ └── tsconfig.json │ ├── genkit/ │ │ ├── bin/ │ │ │ └── weather.ts │ │ ├── package.json │ │ └── tsconfig.json │ ├── langchain/ │ │ ├── bin/ │ │ │ └── weather.ts │ │ ├── package.json │ │ └── tsconfig.json │ ├── llamaindex/ │ │ ├── bin/ │ │ │ └── weather.ts │ │ ├── package.json │ │ └── tsconfig.json │ ├── mastra/ │ │ ├── bin/ │ │ │ └── weather.ts │ │ ├── package.json │ │ └── tsconfig.json │ └── openai/ │ ├── bin/ │ │ ├── weather-responses.ts │ │ └── weather.ts │ ├── package.json │ └── tsconfig.json ├── fixtures/ │ ├── invalid/ │ │ ├── invalid-metadata-0/ │ │ │ └── agentic.config.ts │ │ ├── invalid-metadata-1/ │ │ │ └── agentic.config.ts │ │ ├── invalid-name-0/ │ │ │ └── agentic.config.ts │ │ ├── invalid-name-1/ │ │ │ └── agentic.config.ts │ │ ├── invalid-name-2/ │ │ │ └── agentic.config.ts │ │ ├── invalid-origin-url-0/ │ │ │ └── agentic.config.ts │ │ ├── invalid-origin-url-1/ │ │ │ └── agentic.config.ts │ │ ├── invalid-origin-url-2/ │ │ │ └── agentic.config.json │ │ ├── invalid-origin-url-3/ │ │ │ └── agentic.config.ts │ │ ├── invalid-slug-0/ │ │ │ └── agentic.config.ts │ │ ├── invalid-slug-1/ │ │ │ └── agentic.config.ts │ │ ├── invalid-slug-2/ │ │ │ └── agentic.config.ts │ │ ├── invalid-slug-3/ │ │ │ └── agentic.config.ts │ │ ├── invalid-slug-4/ │ │ │ └── agentic.config.ts │ │ ├── pricing-base-inconsistent/ │ │ │ └── agentic.config.ts │ │ ├── pricing-custom-inconsistent/ │ │ │ └── agentic.config.ts │ │ ├── pricing-duplicate-0/ │ │ │ └── agentic.config.ts │ │ ├── pricing-duplicate-1/ │ │ │ └── agentic.config.ts │ │ ├── pricing-empty-0/ │ │ │ └── agentic.config.ts │ │ ├── pricing-empty-1/ │ │ │ └── agentic.config.ts │ │ └── pricing-empty-2/ │ │ └── agentic.config.ts │ ├── package.json │ ├── tsconfig.json │ └── valid/ │ ├── basic-mcp/ │ │ ├── agentic.config.ts │ │ ├── package.json │ │ ├── src/ │ │ │ ├── env.ts │ │ │ └── index.ts │ │ └── tsconfig.json │ ├── basic-openapi/ │ │ ├── agentic.config.ts │ │ └── jsonplaceholder.json │ ├── basic-raw-free-json/ │ │ └── agentic.config.json │ ├── basic-raw-free-ts/ │ │ └── agentic.config.ts │ ├── everything-openapi/ │ │ ├── agentic.config.ts │ │ ├── package.json │ │ ├── readme.md │ │ ├── src/ │ │ │ ├── exit-hooks.ts │ │ │ ├── lib/ │ │ │ │ └── db.ts │ │ │ ├── routes/ │ │ │ │ ├── custom-cache-control-tool.ts │ │ │ │ ├── custom-rate-limit-approximate-tool.ts │ │ │ │ ├── custom-rate-limit-tool.ts │ │ │ │ ├── disabled-for-free-plan-tool.ts │ │ │ │ ├── disabled-rate-limit-tool.ts │ │ │ │ ├── disabled-tool.ts │ │ │ │ ├── echo-headers.ts │ │ │ │ ├── echo.ts │ │ │ │ ├── get-user.ts │ │ │ │ ├── health-check.ts │ │ │ │ ├── no-cache-cache-control-tool.ts │ │ │ │ ├── no-store-cache-control-tool.ts │ │ │ │ ├── pure.ts │ │ │ │ ├── strict-additional-properties.ts │ │ │ │ └── unpure-marked-pure.ts │ │ │ └── server.ts │ │ └── tsconfig.json │ ├── metadata-0/ │ │ ├── agentic.config.ts │ │ └── readme.md │ ├── metadata-1/ │ │ └── agentic.config.ts │ ├── metadata-2/ │ │ └── agentic.config.ts │ ├── pricing-3-plans/ │ │ └── agentic.config.ts │ ├── pricing-custom-0/ │ │ └── agentic.config.ts │ ├── pricing-freemium/ │ │ └── agentic.config.ts │ ├── pricing-monthly-annual/ │ │ └── agentic.config.ts │ └── pricing-pay-as-you-go/ │ └── agentic.config.ts ├── legacy/ │ ├── .editorconfig │ ├── .github/ │ │ ├── funding.yml │ │ └── workflows/ │ │ ├── main.yml │ │ └── release.yml │ ├── .gitignore │ ├── .husky/ │ │ └── _/ │ │ └── pre-commit │ ├── .npmrc │ ├── .prettierignore │ ├── .vscode/ │ │ └── launch.json │ ├── docs/ │ │ ├── intro.mdx │ │ ├── mint.json │ │ ├── quickstart.mdx │ │ ├── scratch.md │ │ ├── sdks/ │ │ │ ├── genaiscript.mdx │ │ │ └── xsai.mdx │ │ ├── tools/ │ │ │ ├── airtable.mdx │ │ │ ├── apollo.mdx │ │ │ ├── arxiv.mdx │ │ │ ├── bing.mdx │ │ │ ├── brave-search.mdx │ │ │ ├── calculator.mdx │ │ │ ├── clearbit.mdx │ │ │ ├── dexa.mdx │ │ │ ├── diffbot.mdx │ │ │ ├── duck-duck-go.mdx │ │ │ ├── e2b.mdx │ │ │ ├── exa.mdx │ │ │ ├── firecrawl.mdx │ │ │ ├── google-custom-search.mdx │ │ │ ├── google-docs.mdx │ │ │ ├── google-drive.mdx │ │ │ ├── gravatar.mdx │ │ │ ├── hacker-news.mdx │ │ │ ├── hunter.mdx │ │ │ ├── jina.mdx │ │ │ ├── leadmagic.mdx │ │ │ ├── mcp.mdx │ │ │ ├── midjourney.mdx │ │ │ ├── notion.mdx │ │ │ ├── novu.mdx │ │ │ ├── open-meteo.mdx │ │ │ ├── people-data-labs.mdx │ │ │ ├── perigon.mdx │ │ │ ├── polygon.mdx │ │ │ ├── predict-leads.mdx │ │ │ ├── proxycurl.mdx │ │ │ ├── reddit.mdx │ │ │ ├── rocketreach.mdx │ │ │ ├── searxng.mdx │ │ │ ├── serpapi.mdx │ │ │ ├── serper.mdx │ │ │ ├── slack.mdx │ │ │ ├── social-data.mdx │ │ │ ├── tavily.mdx │ │ │ ├── twilio.mdx │ │ │ ├── twitter.mdx │ │ │ ├── typeform.mdx │ │ │ ├── weather.mdx │ │ │ ├── wikidata.mdx │ │ │ ├── wikipedia.mdx │ │ │ ├── wolfram-alpha.mdx │ │ │ ├── youtube.mdx │ │ │ └── zoominfo.mdx │ │ └── usage.mdx │ ├── eslint.config.js │ ├── examples/ │ │ ├── dexter/ │ │ │ ├── bin/ │ │ │ │ ├── code-interpreter.ts │ │ │ │ ├── election-news.ts │ │ │ │ └── weather.ts │ │ │ ├── package.json │ │ │ └── tsconfig.json │ │ ├── playground/ │ │ │ ├── bin/ │ │ │ │ └── scratch.ts │ │ │ ├── package.json │ │ │ └── tsconfig.json │ │ └── xsai/ │ │ ├── bin/ │ │ │ └── weather.ts │ │ ├── package.json │ │ └── tsconfig.json │ ├── license │ ├── package.json │ ├── packages/ │ │ ├── airtable/ │ │ │ ├── package.json │ │ │ ├── src/ │ │ │ │ ├── airtable-client.ts │ │ │ │ ├── airtable.ts │ │ │ │ └── index.ts │ │ │ └── tsconfig.json │ │ ├── apollo/ │ │ │ ├── package.json │ │ │ ├── src/ │ │ │ │ ├── apollo-client.ts │ │ │ │ └── index.ts │ │ │ └── tsconfig.json │ │ ├── arxiv/ │ │ │ ├── package.json │ │ │ ├── src/ │ │ │ │ ├── arxiv-client.ts │ │ │ │ ├── index.ts │ │ │ │ └── utils.ts │ │ │ └── tsconfig.json │ │ ├── bing/ │ │ │ ├── package.json │ │ │ ├── src/ │ │ │ │ ├── bing-client.ts │ │ │ │ └── index.ts │ │ │ └── tsconfig.json │ │ ├── brave-search/ │ │ │ ├── package.json │ │ │ ├── src/ │ │ │ │ ├── brave-search-client.ts │ │ │ │ ├── brave-search.ts │ │ │ │ └── index.ts │ │ │ └── tsconfig.json │ │ ├── calculator/ │ │ │ ├── package.json │ │ │ ├── src/ │ │ │ │ ├── calculator.ts │ │ │ │ └── index.ts │ │ │ └── tsconfig.json │ │ ├── clearbit/ │ │ │ ├── package.json │ │ │ ├── src/ │ │ │ │ ├── clearbit-client.ts │ │ │ │ └── index.ts │ │ │ └── tsconfig.json │ │ ├── diffbot/ │ │ │ ├── package.json │ │ │ ├── src/ │ │ │ │ ├── diffbot-client.ts │ │ │ │ └── index.ts │ │ │ └── tsconfig.json │ │ ├── duck-duck-go/ │ │ │ ├── package.json │ │ │ ├── src/ │ │ │ │ ├── duck-duck-go-client.ts │ │ │ │ ├── index.ts │ │ │ │ └── paginate.ts │ │ │ └── tsconfig.json │ │ ├── e2b/ │ │ │ ├── package.json │ │ │ ├── src/ │ │ │ │ ├── e2b.ts │ │ │ │ └── index.ts │ │ │ └── tsconfig.json │ │ ├── exa/ │ │ │ ├── package.json │ │ │ ├── src/ │ │ │ │ ├── exa-client.ts │ │ │ │ └── index.ts │ │ │ └── tsconfig.json │ │ ├── firecrawl/ │ │ │ ├── package.json │ │ │ ├── src/ │ │ │ │ ├── firecrawl-client.test.ts │ │ │ │ ├── firecrawl-client.ts │ │ │ │ └── index.ts │ │ │ └── tsconfig.json │ │ ├── github/ │ │ │ ├── package.json │ │ │ ├── src/ │ │ │ │ ├── github-client.ts │ │ │ │ └── index.ts │ │ │ └── tsconfig.json │ │ ├── google-custom-search/ │ │ │ ├── package.json │ │ │ ├── src/ │ │ │ │ ├── google-custom-search-client.ts │ │ │ │ ├── index.ts │ │ │ │ └── paginate.ts │ │ │ └── tsconfig.json │ │ ├── google-docs/ │ │ │ ├── package.json │ │ │ ├── src/ │ │ │ │ ├── google-docs-client.ts │ │ │ │ └── index.ts │ │ │ └── tsconfig.json │ │ ├── google-drive/ │ │ │ ├── package.json │ │ │ ├── src/ │ │ │ │ ├── google-drive-client.ts │ │ │ │ └── index.ts │ │ │ └── tsconfig.json │ │ ├── gravatar/ │ │ │ ├── package.json │ │ │ ├── src/ │ │ │ │ ├── gravatar-client.ts │ │ │ │ └── index.ts │ │ │ └── tsconfig.json │ │ ├── hacker-news/ │ │ │ ├── package.json │ │ │ ├── src/ │ │ │ │ ├── hacker-news-client.ts │ │ │ │ └── index.ts │ │ │ └── tsconfig.json │ │ ├── hunter/ │ │ │ ├── package.json │ │ │ ├── src/ │ │ │ │ ├── hunter-client.ts │ │ │ │ └── index.ts │ │ │ └── tsconfig.json │ │ ├── jigsawstack/ │ │ │ ├── package.json │ │ │ ├── src/ │ │ │ │ ├── index.ts │ │ │ │ ├── integration.test.ts │ │ │ │ ├── jigsawstack-client.ts │ │ │ │ └── tool.test.ts │ │ │ └── tsconfig.json │ │ ├── jina/ │ │ │ ├── package.json │ │ │ ├── src/ │ │ │ │ ├── index.ts │ │ │ │ └── jina-client.ts │ │ │ └── tsconfig.json │ │ ├── leadmagic/ │ │ │ ├── package.json │ │ │ ├── src/ │ │ │ │ ├── index.ts │ │ │ │ └── leadmagic-client.ts │ │ │ └── tsconfig.json │ │ ├── midjourney/ │ │ │ ├── package.json │ │ │ ├── src/ │ │ │ │ ├── index.ts │ │ │ │ └── midjourney-client.ts │ │ │ └── tsconfig.json │ │ ├── notion/ │ │ │ ├── package.json │ │ │ ├── src/ │ │ │ │ ├── index.ts │ │ │ │ ├── notion-client.ts │ │ │ │ └── notion.ts │ │ │ └── tsconfig.json │ │ ├── novu/ │ │ │ ├── package.json │ │ │ ├── src/ │ │ │ │ ├── index.ts │ │ │ │ └── novu-client.ts │ │ │ └── tsconfig.json │ │ ├── open-meteo/ │ │ │ ├── package.json │ │ │ ├── src/ │ │ │ │ ├── index.ts │ │ │ │ ├── open-meteo-client.ts │ │ │ │ └── open-meteo.ts │ │ │ └── tsconfig.json │ │ ├── openapi-to-ts/ │ │ │ ├── bin/ │ │ │ │ └── openapi-to-ts.ts │ │ │ ├── fixtures/ │ │ │ │ ├── generated/ │ │ │ │ │ ├── firecrawl-client.ts │ │ │ │ │ ├── firecrawl.ts │ │ │ │ │ ├── github-client.ts │ │ │ │ │ ├── github.ts │ │ │ │ │ ├── notion-client.ts │ │ │ │ │ ├── notion.ts │ │ │ │ │ ├── open-meteo-client.ts │ │ │ │ │ ├── open-meteo.ts │ │ │ │ │ ├── pet-store-client.ts │ │ │ │ │ ├── pet-store.ts │ │ │ │ │ ├── petstore-expanded-client.ts │ │ │ │ │ ├── petstore-expanded.ts │ │ │ │ │ ├── security-client.ts │ │ │ │ │ ├── security.ts │ │ │ │ │ ├── tic-tac-toe-client.ts │ │ │ │ │ └── tic-tac-toe.ts │ │ │ │ └── openapi/ │ │ │ │ ├── firecrawl.json │ │ │ │ ├── github.json │ │ │ │ ├── notion.json │ │ │ │ ├── open-meteo.yaml │ │ │ │ ├── pet-store.json │ │ │ │ ├── petstore-expanded.json │ │ │ │ ├── readme.json │ │ │ │ ├── security.json │ │ │ │ ├── stripe.json │ │ │ │ └── tic-tac-toe.json │ │ │ ├── package.json │ │ │ ├── src/ │ │ │ │ ├── __snapshots__/ │ │ │ │ │ └── generate-ts-from-openapi.test.ts.snap │ │ │ │ ├── generate-ts-from-openapi.test.ts │ │ │ │ ├── generate-ts-from-openapi.ts │ │ │ │ ├── index.ts │ │ │ │ ├── openapi-parameters-to-json-schema.ts │ │ │ │ ├── types.ts │ │ │ │ └── utils.ts │ │ │ ├── tsconfig.json │ │ │ └── tsup.config.ts │ │ ├── people-data-labs/ │ │ │ ├── package.json │ │ │ ├── src/ │ │ │ │ ├── index.ts │ │ │ │ └── people-data-labs-client.ts │ │ │ └── tsconfig.json │ │ ├── perigon/ │ │ │ ├── package.json │ │ │ ├── src/ │ │ │ │ ├── index.ts │ │ │ │ └── perigon-client.ts │ │ │ └── tsconfig.json │ │ ├── polygon/ │ │ │ ├── package.json │ │ │ ├── src/ │ │ │ │ ├── index.ts │ │ │ │ └── polygon-client.ts │ │ │ └── tsconfig.json │ │ ├── predict-leads/ │ │ │ ├── package.json │ │ │ ├── src/ │ │ │ │ ├── index.ts │ │ │ │ └── predict-leads-client.ts │ │ │ └── tsconfig.json │ │ ├── proxycurl/ │ │ │ ├── package.json │ │ │ ├── src/ │ │ │ │ ├── index.ts │ │ │ │ └── proxycurl-client.ts │ │ │ └── tsconfig.json │ │ ├── reddit/ │ │ │ ├── package.json │ │ │ ├── src/ │ │ │ │ ├── index.ts │ │ │ │ └── reddit-client.ts │ │ │ └── tsconfig.json │ │ ├── rocketreach/ │ │ │ ├── package.json │ │ │ ├── src/ │ │ │ │ ├── index.ts │ │ │ │ └── rocketreach-client.ts │ │ │ └── tsconfig.json │ │ ├── searxng/ │ │ │ ├── package.json │ │ │ ├── src/ │ │ │ │ ├── index.ts │ │ │ │ └── searxng-client.ts │ │ │ └── tsconfig.json │ │ ├── slack/ │ │ │ ├── package.json │ │ │ ├── src/ │ │ │ │ ├── index.ts │ │ │ │ ├── slack-client.ts │ │ │ │ └── slack.ts │ │ │ └── tsconfig.json │ │ ├── social-data/ │ │ │ ├── package.json │ │ │ ├── src/ │ │ │ │ ├── index.ts │ │ │ │ └── social-data-client.ts │ │ │ └── tsconfig.json │ │ ├── stdlib/ │ │ │ ├── package.json │ │ │ ├── src/ │ │ │ │ └── index.ts │ │ │ └── tsconfig.json │ │ ├── tavily/ │ │ │ ├── package.json │ │ │ ├── src/ │ │ │ │ ├── index.ts │ │ │ │ └── tavily-client.ts │ │ │ └── tsconfig.json │ │ ├── twilio/ │ │ │ ├── package.json │ │ │ ├── src/ │ │ │ │ ├── index.ts │ │ │ │ └── twilio-client.ts │ │ │ └── tsconfig.json │ │ ├── twitter/ │ │ │ ├── package.json │ │ │ ├── src/ │ │ │ │ ├── client.ts │ │ │ │ ├── error.ts │ │ │ │ ├── index.ts │ │ │ │ ├── nango.ts │ │ │ │ ├── twitter-client.ts │ │ │ │ ├── types.ts │ │ │ │ └── utils.ts │ │ │ └── tsconfig.json │ │ ├── typeform/ │ │ │ ├── package.json │ │ │ ├── src/ │ │ │ │ ├── index.ts │ │ │ │ └── typeform-client.ts │ │ │ └── tsconfig.json │ │ ├── weather/ │ │ │ ├── package.json │ │ │ ├── src/ │ │ │ │ ├── index.ts │ │ │ │ └── weather-client.ts │ │ │ └── tsconfig.json │ │ ├── wikidata/ │ │ │ ├── package.json │ │ │ ├── src/ │ │ │ │ ├── index.ts │ │ │ │ └── wikidata-client.ts │ │ │ └── tsconfig.json │ │ ├── wikipedia/ │ │ │ ├── package.json │ │ │ ├── src/ │ │ │ │ ├── index.ts │ │ │ │ └── wikipedia-client.ts │ │ │ └── tsconfig.json │ │ ├── wolfram-alpha/ │ │ │ ├── package.json │ │ │ ├── src/ │ │ │ │ ├── index.ts │ │ │ │ └── wolfram-alpha-client.ts │ │ │ └── tsconfig.json │ │ ├── xsai/ │ │ │ ├── package.json │ │ │ ├── src/ │ │ │ │ ├── index.ts │ │ │ │ ├── xsai.test.ts │ │ │ │ └── xsai.ts │ │ │ └── tsconfig.json │ │ ├── youtube/ │ │ │ ├── package.json │ │ │ ├── src/ │ │ │ │ ├── index.ts │ │ │ │ └── youtube-client.ts │ │ │ └── tsconfig.json │ │ └── zoominfo/ │ │ ├── package.json │ │ ├── src/ │ │ │ ├── index.ts │ │ │ └── zoominfo-client.ts │ │ └── tsconfig.json │ ├── pnpm-workspace.yaml │ ├── readme.md │ ├── tsconfig.json │ ├── tsup.config.ts │ ├── turbo.json │ └── vite.config.ts ├── license ├── package.json ├── packages/ │ ├── api-client/ │ │ ├── package.json │ │ ├── readme.md │ │ ├── src/ │ │ │ ├── agentic-api-client.ts │ │ │ ├── index.ts │ │ │ └── types.ts │ │ └── tsconfig.json │ ├── cli/ │ │ ├── package.json │ │ ├── readme.md │ │ ├── src/ │ │ │ ├── cli.ts │ │ │ ├── commands/ │ │ │ │ ├── debug.ts │ │ │ │ ├── deploy.ts │ │ │ │ ├── get.ts │ │ │ │ ├── list.ts │ │ │ │ ├── publish.ts │ │ │ │ ├── signin.ts │ │ │ │ ├── signout.ts │ │ │ │ ├── signup.ts │ │ │ │ └── whoami.ts │ │ │ ├── lib/ │ │ │ │ ├── auth-store.ts │ │ │ │ ├── auth.ts │ │ │ │ ├── commander.d.ts │ │ │ │ ├── env.ts │ │ │ │ ├── exit-hooks.ts │ │ │ │ ├── handle-error.ts │ │ │ │ ├── prompt-for-deployment-version.ts │ │ │ │ ├── reset.d.ts │ │ │ │ ├── resolve-deployment.ts │ │ │ │ └── utils.ts │ │ │ └── types.ts │ │ ├── tsconfig.json │ │ └── tsup.config.ts │ ├── emails/ │ │ ├── package.json │ │ ├── readme.md │ │ ├── src/ │ │ │ ├── emails/ │ │ │ │ └── send-verify-code-email.tsx │ │ │ ├── index.ts │ │ │ └── resend-email-client.tsx │ │ └── tsconfig.json │ ├── hono/ │ │ ├── package.json │ │ ├── readme.md │ │ ├── src/ │ │ │ ├── env.ts │ │ │ ├── error-handler.ts │ │ │ ├── header-utils.ts │ │ │ ├── index.ts │ │ │ ├── json-rpc-errors.ts │ │ │ ├── logger/ │ │ │ │ ├── index.ts │ │ │ │ ├── logger.ts │ │ │ │ └── utils.ts │ │ │ ├── middleware/ │ │ │ │ ├── access-logger.ts │ │ │ │ ├── index.ts │ │ │ │ ├── init.ts │ │ │ │ ├── response-time.ts │ │ │ │ └── unless.ts │ │ │ ├── sentry.test.ts │ │ │ ├── sentry.ts │ │ │ └── types.ts │ │ └── tsconfig.json │ ├── json-schema/ │ │ ├── license │ │ ├── package.json │ │ ├── readme.md │ │ ├── src/ │ │ │ ├── coercion.ts │ │ │ ├── deep-compare-strict.ts │ │ │ ├── dereference.ts │ │ │ ├── format.ts │ │ │ ├── index.ts │ │ │ ├── pointer.ts │ │ │ ├── types.ts │ │ │ ├── ucs2-length.ts │ │ │ ├── validate.ts │ │ │ └── validator.ts │ │ ├── test/ │ │ │ ├── coercion.test.ts │ │ │ ├── index.test.ts │ │ │ ├── json-schema-test-suite.ts │ │ │ ├── meta-schema.ts │ │ │ ├── types.ts │ │ │ ├── unsupported.ts │ │ │ └── validator.spec.ts │ │ └── tsconfig.json │ ├── openapi-utils/ │ │ ├── fixtures/ │ │ │ ├── basic.json │ │ │ ├── firecrawl.json │ │ │ ├── mixed.json │ │ │ ├── notion.json │ │ │ ├── open-meteo.yaml │ │ │ ├── pet-store.json │ │ │ ├── petstore-expanded.json │ │ │ ├── readme.json │ │ │ ├── security.json │ │ │ └── tic-tac-toe.json │ │ ├── package.json │ │ ├── readme.md │ │ ├── src/ │ │ │ ├── __snapshots__/ │ │ │ │ ├── get-tools-from-openapi-spec.test.ts.snap │ │ │ │ └── validate-openapi-spec.test.ts.snap │ │ │ ├── get-tools-from-openapi-spec.test.ts │ │ │ ├── get-tools-from-openapi-spec.ts │ │ │ ├── index.ts │ │ │ ├── openapi-parameters-to-json-schema.ts │ │ │ ├── redocly-config.ts │ │ │ ├── types.ts │ │ │ ├── utils.ts │ │ │ ├── validate-json-schema-object.ts │ │ │ ├── validate-openapi-spec.test.ts │ │ │ └── validate-openapi-spec.ts │ │ └── tsconfig.json │ ├── platform/ │ │ ├── package.json │ │ ├── readme.md │ │ ├── src/ │ │ │ ├── __snapshots__/ │ │ │ │ └── load-agentic-config.test.ts.snap │ │ │ ├── define-config.ts │ │ │ ├── index.ts │ │ │ ├── load-agentic-config.test.ts │ │ │ ├── load-agentic-config.ts │ │ │ ├── origin-adapters/ │ │ │ │ ├── mcp.ts │ │ │ │ └── openapi.ts │ │ │ ├── parse-agentic-project-config.ts │ │ │ ├── resolve-agentic-project-config.ts │ │ │ ├── resolve-metadata-file.ts │ │ │ ├── resolve-metadata-files.ts │ │ │ ├── resolve-metadata.ts │ │ │ ├── resolve-origin-adapter.ts │ │ │ ├── types.ts │ │ │ ├── validate-agentic-project-config.ts │ │ │ ├── validate-metadata-file.ts │ │ │ ├── validate-metadata-files.ts │ │ │ ├── validate-origin-adapter.ts │ │ │ ├── validate-origin-url.ts │ │ │ ├── validate-pricing.ts │ │ │ └── validate-tools.ts │ │ └── tsconfig.json │ ├── platform-core/ │ │ ├── package.json │ │ ├── readme.md │ │ ├── src/ │ │ │ ├── __snapshots__/ │ │ │ │ └── utils.test.ts.snap │ │ │ ├── errors.ts │ │ │ ├── hash-object.test.ts │ │ │ ├── hash-object.ts │ │ │ ├── index.ts │ │ │ ├── rate-limit-headers.ts │ │ │ ├── types.test.ts │ │ │ ├── types.ts │ │ │ ├── utils.test.ts │ │ │ └── utils.ts │ │ └── tsconfig.json │ ├── tool-client/ │ │ ├── package.json │ │ ├── readme.md │ │ ├── src/ │ │ │ ├── agentic-tool-client.ts │ │ │ └── index.ts │ │ └── tsconfig.json │ ├── types/ │ │ ├── bin/ │ │ │ └── generate-project-config-json-schema.ts │ │ ├── package.json │ │ ├── readme.md │ │ ├── src/ │ │ │ ├── __snapshots__/ │ │ │ │ └── rate-limit.test.ts.snap │ │ │ ├── agentic-project-config.test.ts │ │ │ ├── agentic-project-config.ts │ │ │ ├── auth-subjects.ts │ │ │ ├── index.ts │ │ │ ├── mcp.ts │ │ │ ├── openapi.d.ts │ │ │ ├── origin-adapter.ts │ │ │ ├── pricing.test.ts │ │ │ ├── pricing.ts │ │ │ ├── rate-limit.test.ts │ │ │ ├── rate-limit.ts │ │ │ ├── temp │ │ │ ├── tools.ts │ │ │ ├── types.ts │ │ │ ├── utils.ts │ │ │ └── webhook.ts │ │ └── tsconfig.json │ └── validators/ │ ├── package.json │ ├── readme.md │ ├── src/ │ │ ├── __snapshots__/ │ │ │ ├── parse-deployment-identifier.test.ts.snap │ │ │ ├── parse-project-identifier.test.ts.snap │ │ │ └── parse-tool-identifier.test.ts.snap │ │ ├── index.ts │ │ ├── namespace-blacklist.ts │ │ ├── parse-deployment-identifier.test.ts │ │ ├── parse-deployment-identifier.ts │ │ ├── parse-project-identifier.test.ts │ │ ├── parse-project-identifier.ts │ │ ├── parse-tool-identifier.test.ts │ │ ├── parse-tool-identifier.ts │ │ ├── tool-name-blacklist.ts │ │ ├── types.ts │ │ ├── utils.ts │ │ ├── validators.test.ts │ │ └── validators.ts │ └── tsconfig.json ├── pnpm-workspace.yaml ├── readme.md ├── stdlib/ │ ├── ai-sdk/ │ │ ├── package.json │ │ ├── readme.md │ │ ├── src/ │ │ │ ├── ai-sdk.test.ts │ │ │ ├── ai-sdk.ts │ │ │ └── index.ts │ │ └── tsconfig.json │ ├── core/ │ │ ├── package.json │ │ ├── readme.md │ │ ├── src/ │ │ │ ├── __snapshots__/ │ │ │ │ ├── parse-structured-output.test.ts.snap │ │ │ │ └── utils.test.ts.snap │ │ │ ├── _utils.ts │ │ │ ├── ai-function-set.test.ts │ │ │ ├── ai-function-set.ts │ │ │ ├── assert.ts │ │ │ ├── create-ai-function.test.ts │ │ │ ├── create-ai-function.ts │ │ │ ├── echo.ts │ │ │ ├── errors.ts │ │ │ ├── fns.ts │ │ │ ├── index.ts │ │ │ ├── message.test.ts │ │ │ ├── message.ts │ │ │ ├── parse-structured-output.test.ts │ │ │ ├── parse-structured-output.ts │ │ │ ├── reset.d.ts │ │ │ ├── schema.test.ts │ │ │ ├── schema.ts │ │ │ ├── types.ts │ │ │ ├── utils.test.ts │ │ │ ├── utils.ts │ │ │ ├── zod-to-json-schema.test.ts │ │ │ └── zod-to-json-schema.ts │ │ └── tsconfig.json │ ├── genkit/ │ │ ├── package.json │ │ ├── readme.md │ │ ├── src/ │ │ │ ├── genkit.test.ts │ │ │ ├── genkit.ts │ │ │ └── index.ts │ │ ├── tsconfig.json │ │ └── vitest.config.ts │ ├── langchain/ │ │ ├── package.json │ │ ├── readme.md │ │ ├── src/ │ │ │ ├── index.ts │ │ │ ├── langchain.test.ts │ │ │ └── langchain.ts │ │ └── tsconfig.json │ ├── license │ ├── llamaindex/ │ │ ├── package.json │ │ ├── readme.md │ │ ├── src/ │ │ │ ├── index.ts │ │ │ ├── llamaindex.test.ts │ │ │ └── llamaindex.ts │ │ └── tsconfig.json │ ├── mastra/ │ │ ├── package.json │ │ ├── readme.md │ │ ├── src/ │ │ │ ├── index.ts │ │ │ ├── mastra.test.ts │ │ │ └── mastra.ts │ │ └── tsconfig.json │ ├── mcp/ │ │ ├── package.json │ │ ├── src/ │ │ │ ├── index.ts │ │ │ ├── mcp-tools.ts │ │ │ ├── paginate.ts │ │ │ └── types.ts │ │ └── tsconfig.json │ ├── serpapi/ │ │ ├── package.json │ │ ├── readme.md │ │ ├── src/ │ │ │ ├── index.ts │ │ │ └── serpapi-client.ts │ │ └── tsconfig.json │ └── serper/ │ ├── package.json │ ├── readme.md │ ├── src/ │ │ ├── index.ts │ │ └── serper-client.ts │ └── tsconfig.json ├── todo.md ├── tsconfig.json ├── tsup.config.ts └── turbo.json ================================================ FILE CONTENTS ================================================ ================================================ FILE: .cursor/rules/general.mdc ================================================ --- description: globs: alwaysApply: false --- --- description: General TypeScript coding guidelines globs: --- ## General - Write elegant, concise, and readable code - Prefer `const` over `let` (never use `var`) - Use kebab-case for file and directory names - Use clear, descriptive names for variables, functions, and components ## Modules ### Imports & Exports - Always use ESM `import` and `export` (never use CJS `require`) - File imports should never use an extension (NOT `.js`, `.ts` or `.tsx`). - GOOD examples: - `import { Foo } from './foo'` - `import { type Route } from './types/root'` - `import zod from 'zod'` - `import { logger } from '~/types'` - BAD examples: - `import { Foo } from './foo.js'` - `import { type Route } from './types/root.js'` - `import { Foo } from './foo.ts'` - Always prefer named exports over default exports ### Packages All packages must follow these `package.json` rules: - `type` must be set to `module` ## TypeScript - Avoid semicolons at the end of lines - Use TypeScript's utility types (e.g., `Partial`, `Pick`, `Omit`) to manipulate existing types - Create custom types for complex data structures used throughout the application - If possible, avoid using `any`/`unknown` or casting values like `(value as any)` in TypeScript outside of test files e.g. `*.test.ts` or test fixtures e.g. `**/test-data.ts`. - Don't rely on `typeof`, `ReturnType<>`, `Awaited<>`, etc for complex type inference (it's ok for simple types) - You can use `as const` as needed for better type inference - Functions should accept an object parameter instead of multiple parameters - Good examples: ```ts function myFunction({ foo, bar }: { foo: boolean; bar: string }) {} function VideoPlayer({ sid }: { sid: string }) {} ``` - Bad examples: ```ts function myFunction(foo: boolean, bar: string, baz: number) {} ``` - Arguments should generally be destructured in the function definition, not the function body. - Good example: ```ts function myFunction({ foo, bar }: { foo: boolean; bar: string }) {} ``` - Bad example: ```ts function myFunction(args: { foo: boolean; bar: string }) { const { foo, bar } = args } ``` - Zod should be used to parse untrusted data, but not for data that is trusted like function arguments - Prefer Zod unions over Zod enums - For example, this union `z.union([ z.literal('youtube'), z.literal('spotify') ])` is better than this enum `z.enum([ 'youtube', 'spotify' ])` - Promises (and `async` functions which implicitly create Promises) must always be properly handled, either via: - Using `await` to wait for the Promise to resolve successfully - Using `.then` or `.catch` to handle Promise resolution - Returning a Promise to a calling function which itself has to handle the Promise. ## Node.js - Utilize the `node:` protocol when importing Node.js modules (e.g., `import fs from 'node:fs/promises'`) - Prefer promise-based APIs over Node's legacy callback APIs - Use environment variables for secrets (avoid hardcoding sensitive information) ### Web Standard APIs Always prefer using standard web APIs like `fetch`, `WebSocket`, and `ReadableStream` when possible. Avoid redundant libraries (like `node-fetch`). - Prefer the `fetch` API for making HTTP requests instead of Node.js modules like `http` or `https` - Use the native `fetch` API instead of `node-fetch` or polyfilled `cross-fetch` - Use the `ky` library for HTTP requests instead of `axios` or `superagent` - Use the WHATWG `URL` and `URLSearchParams` classes instead of the Node.js `url` module - Use `Request` and `Response` objects from the Fetch API instead of Node.js-specific request and response objects ## Error Handling - Prefer `async`/`await` over `.then()` and `.catch()` - Always handle errors correctly (eg: `try`/`catch` or `.catch()`) - Avoid swallowing errors silently; always log or handle caught errors appropriately ## Comments Comments should be used to document and explain code. They should complement the use of descriptive variable and function names and type declarations. - Add comments to explain complex sections of code - Add comments that will improve the autocompletion preview in IDEs (eg: functions and types) - Don't add comments that just reword symbol names or repeat type declarations - Use **JSDoc** formatting for comments (not TSDoc or inline comments) ## Logging - Just use `console` for logging. ## Testing ### Unit Testing - **All unit tests should use Vitest** - DO NOT attempt to install or use other testing libraries like Jest - Test files should be named `[target].test.ts` and placed in the same directory as the code they are testing (NOT a separate directory) - Good example: `src/my-file.ts` and `src/my-file.test.ts` - Bad example: `src/my-file.ts` and `src/test/my-file.test.ts` or `test/my-file.test.ts` or `src/__tests__/my-file.test.ts` - Tests should be run with `pnpm test:unit` - It's acceptable to use `any`/`unknown` in test files (such as `*.test.ts`) or test fixtures (like `**/test-data.ts`) to facilitate mocking or stubbing external modules or partial function arguments, referencing the usage guidelines in the TypeScript section. - Frontend react code does not need unit tests ### Test Coverage - Test critical business logic and edge cases - Don't add tests for trivial code or just to increase test coverage - Don't make tests too brittle or flaky by relying on implementation details ## Git - When possible, combine the `git add` and `git commit` commands into a single `git commit -am` command, to speed things up ================================================ FILE: .editorconfig ================================================ root = true [*] indent_style = space indent_size = 2 tab_width = 2 end_of_line = lf charset = utf-8 trim_trailing_whitespace = true insert_final_newline = true ================================================ FILE: .github/funding.yml ================================================ github: [transitive-bullshit] ================================================ FILE: .github/workflows/main.yml ================================================ name: CI on: [push] jobs: test: name: Test Node.js ${{ matrix.node-version }} runs-on: ubuntu-latest strategy: fail-fast: true matrix: node-version: - 22 env: TURBO_TOKEN: ${{ secrets.TURBO_TOKEN }} TURBO_TEAM: ${{ vars.TURBO_TEAM }} NODE_OPTIONS: --max-old-space-size=8192 steps: - uses: actions/checkout@v4 - uses: pnpm/action-setup@v4 - uses: actions/setup-node@v4 with: node-version: ${{ matrix.node-version }} cache: 'pnpm' - run: pnpm install --frozen-lockfile --strict-peer-dependencies - name: Cache turbo build setup uses: actions/cache@v4 with: path: .turbo key: ${{ runner.os }}-node-${{ matrix.node-version }}-turbo-${{ github.sha }} restore-keys: | ${{ runner.os }}-node-${{ matrix.node-version }}-turbo- - run: pnpm build - run: pnpm test ================================================ FILE: .github/workflows/release.yml ================================================ name: Release on: push: tags: - 'v*' workflow_dispatch: jobs: release: runs-on: ubuntu-latest permissions: contents: write steps: - uses: actions/checkout@v4 with: fetch-depth: 0 - uses: pnpm/action-setup@v4 - uses: actions/setup-node@v4 with: node-version: lts/* cache: pnpm - run: pnpm dlx changelogithub env: GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} ================================================ FILE: .gitignore ================================================ # See https://help.github.com/articles/ignoring-files/ for more about ignoring files. # dependencies node_modules /.pnp .pnp.js # testing /coverage # next.js .next/ # production build/ dist/ # misc .DS_Store *.pem # debug npm-debug.log* yarn-debug.log* yarn-error.log* .pnpm-debug.log* # dotenv files .env .env.production .env.staging .env.test .env*.local # cloudflare env vars .dev.vars .dev.vars.production .dev.vars.staging .dev.vars.test # turbo .turbo # vercel .vercel # typescript *.tsbuildinfo next-env.d.ts old/ out/ .wrangler .sentryclirc .eslintcache .nitro .tanstack .xmcp ================================================ FILE: .npmrc ================================================ enable-pre-post-scripts=true package-manager-strict=false ================================================ FILE: .prettierignore ================================================ # autogenerated files packages/types/src/openapi.d.ts apps/web/src/routeTree.gen.ts legacy/packages/openapi-to-ts/fixtures/generated examples/mcp-servers/xmcp/xmcp-env.d.ts ================================================ FILE: .vscode/launch.json ================================================ { "version": "0.2.0", "configurations": [ { "name": "Debug API", "type": "node", "request": "launch", // Debug server in VSCode "cwd": "${workspaceFolder}/apps/api", "program": "src/server.ts", // "program": "${file}", /* * Path to tsx binary * Assuming locally installed */ "runtimeExecutable": "tsx", /* * Open terminal when debugging starts (Optional) * Useful to see console.logs */ "console": "integratedTerminal", "internalConsoleOptions": "neverOpen", // Files to exclude from debugger (e.g. call stack) "skipFiles": [ // Node.js internal core modules "/**" // Ignore all dependencies (optional) // "${workspaceFolder}/node_modules/**" ] } // Wrangler's vscode support seems to be extremely buggy. It sometimes works // 1/10th of the time, but nothing I tried could improve that consistency. // Will use browser debugger instead for now. // { // "name": "gateway", // "type": "node", // "request": "attach", // "port": 9229, // "cwd": "${workspaceFolder}/apps/gateway", // // "cwd": "${workspaceFolder}", // // "cwd": "/", // "attachExistingChildren": false, // "autoAttachChildProcesses": false, // "sourceMaps": true, // "outFiles": ["${workspaceFolder}/apps/gateway/.wrangler/tmp/**/*"], // "resolveSourceMapLocations": null, // // "resolveSourceMapLocations": ["**", "!**/node_modules/**"], // "skipFiles": ["/**"], // "internalConsoleOptions": "neverOpen", // "restart": true // }, // { // "name": "Wrangler", // "type": "node", // "request": "attach", // "port": 9229, // "cwd": "/", // "resolveSourceMapLocations": null, // "attachExistingChildren": false, // "autoAttachChildProcesses": false, // "sourceMaps": true // works with or without this line (supposedly) // } ] // "compounds": [ // { // "name": "Debug Workers", // "configurations": ["gateway"], // "stopAll": true // } // ] } ================================================ FILE: CLAUDE.md ================================================ # CLAUDE.md This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository. ## Project Overview This is a monorepo for Agentic - a platform that provides API gateway services for MCP (Model Context Protocol) and OpenAPI integrations. ### Core Architecture The platform consists of: - **API Service** (`apps/api/`) - Platform backend API with authentication, billing, and resource management - **Gateway Service** (`apps/gateway/`) - Cloudflare Worker that proxies requests to origin MCP/OpenAPI services - **Website** (`apps/web/`) - Next.js site for both the marketing site and authenticated webapp - **E2E Tests** (`apps/e2e/`) - End-to-end test suite for HTTP and MCP gateway requests - **Shared Packages** (`packages/`) - Common utilities, types, validators, and config - **StdLib Packages** (`stdlib/`) - TS AI SDK adapters The gateway accepts HTTP requests at `https://gateway.agentic.so/deploymentIdentifier/tool-name` or `https://gateway.agentic.so/deploymentIdentifier/mcp` for MCP. ### Development Commands **Main development workflow:** - `pnpm dev` - Start all services in development mode - `pnpm build` - Build all packages and apps (except for the website) - `pnpm test` - Run all tests (format, lint, typecheck, unit, but not e2e tests) - `pnpm clean` - Clean all build artifacts **Individual test commands:** - `pnpm test:format` - Check code formatting with Prettier - `pnpm test:lint` - Run ESLint across all packages - `pnpm test:typecheck` - Run TypeScript type checking - `pnpm test:unit` - Run unit tests with Vitest **Code quality:** - `pnpm fix` - Auto-fix formatting and linting issues - `pnpm knip` - Check for unused dependencies **E2E testing:** - (from the `apps/e2e` directory) - `pnpm e2e` - Run all E2E tests - `pnpm e2e-http` - Run HTTP edge E2E tests - `pnpm e2e-mcp` - Run MCP edge E2E tests ### Key Database Models The system uses Drizzle ORM with PostgreSQL. Core entities: - **User** - Platform users - **Team** - Organizations with members and billing - **Project** - Namespace API products comprised of immutable Deployments - **Deployment** - Immutable instances of MCP/OpenAPI services, including gateway and pricing config - **Consumer** - Customer subscription tracking usage and billing ### Agentic Configuration Agentic projects use `agentic.config.{ts,js,json}` files to define: - Project name and metadata - Origin adapter (MCP server or OpenAPI spec) - Tool configurations and permissions - Pricing plans and rate limits - Authentication requirements The platform supports both MCP servers and OpenAPI specifications as origin adapters. ### Gateway Request Flow 1. Request hits gateway with deployment identifier 2. Gateway validates consumer authentication/rate limits/caching 3. Request is transformed and forwarded to origin service 4. Response is processed and returned with appropriate headers 5. Usage is tracked for billing and analytics ### Environment Setup All apps require environment variables for: - Database connections (`DATABASE_URL`) - External services (Stripe, GitHub, Resend, Sentry) - Internal services (API, gateway, etc) - Authentication secrets - Stripe secrets - Admin API keys - Sentry DSN - etc ## Coding Conventions ### General - Write elegant, concise, and readable code - Prefer `const` over `let` (never use `var`) - Use kebab-case for file and directory names - Use clear, descriptive names for variables, functions, and components ### Modules - Always use ESM `import` and `export` (never use CJS `require`) - File imports should never use an extension (NOT `.js`, `.ts` or `.tsx`). - GOOD examples: - `import { Foo } from './foo'` - `import { type Route } from './types/root'` - `import zod from 'zod'` - `import { logger } from '~/types'` - BAD examples: - `import { Foo } from './foo.js'` - `import { type Route } from './types/root.js'` - `import { Foo } from './foo.ts'` - Always prefer named exports over default exports except for when default exports are required (like in Next.js `page.tsx` components) ### Packages All packages must follow these `package.json` rules: - `type` must be set to `module` ### TypeScript - Avoid semicolons at the end of lines - Use TypeScript's utility types (e.g., `Partial`, `Pick`, `Omit`) to manipulate existing types - Create custom types for complex data structures used throughout the application - If possible, avoid using `any`/`unknown` or casting values like `(value as any)` in TypeScript outside of test files e.g. `*.test.ts` or test fixtures e.g. `**/test-data.ts`. - Try not to rely on `typeof`, `ReturnType<>`, `Awaited<>`, etc for complex type inference (it's ok for simple types) - You can use `as const` as needed for better type inference - Functions should accept an object parameter instead of multiple parameters - GOOD examples: ```ts function myFunction({ foo, bar }: { foo: boolean; bar: string }) {} function VideoPlayer({ sid }: { sid: string }) {} ``` - BAD examples: ```ts function myFunction(foo: boolean, bar: string, baz: number) {} ``` - Arguments should generally be destructured in the function definition, not the function body. - GOOD example: ```ts function myFunction({ foo, bar }: { foo: boolean; bar: string }) {} function exampleWithOptionalParams({ foo = 'example' }: { foo?: string } = {}) {} ``` - BAD example: ```ts function myFunction(opts: { foo: boolean; bar: string }) { const { foo, bar } = opts } ``` - Zod should be used to parse untrusted data, but not for data that is trusted like function arguments - Prefer Zod unions over Zod enums - For example, this union `z.union([ z.literal('youtube'), z.literal('spotify') ])` is better than this enum `z.enum([ 'youtube', 'spotify' ])` - Promises (and `async` functions which implicitly create Promises) must always be properly handled, either via: - Using `await` to wait for the Promise to resolve successfully - Using `.then` or `.catch` to handle Promise resolution - Returning a Promise to a calling function which itself has to handle the Promise. ## Node.js - Utilize the `node:` protocol when importing Node.js modules (e.g., `import fs from 'node:fs/promises'`) - Prefer promise-based APIs over Node's legacy callback APIs - Use environment variables for secrets (avoid hardcoding sensitive information) ### Web Standard APIs Always prefer using standard web APIs like `fetch`, `WebSocket`, and `ReadableStream` when possible. Avoid redundant libraries (like `node-fetch`). - Prefer the `fetch` API for making HTTP requests instead of Node.js modules like `http` or `https` - Prefer using the `ky` `fetch` wrapper for HTTP requests instead of `axios`, `superagent`, `node-fetch` or any other HTTP request library - Never use `node-fetch`; prefer `ky` or native `fetch` directly - Use the WHATWG `URL` and `URLSearchParams` classes instead of the Node.js `url` module - Use `Request` and `Response` objects from the Fetch API instead of Node.js-specific request and response objects ### Error Handling - Prefer `async`/`await` over `.then()` and `.catch()` - Always handle errors correctly (eg: `try`/`catch` or `.catch()`) - Avoid swallowing errors silently; always log or handle caught errors appropriately ### Comments Comments should be used to document and explain code. They should complement the use of descriptive variable and function names and type declarations. - Add comments to explain complex sections of code - Add comments that will improve the autocompletion preview in IDEs (eg: functions and types) - Don't add comments that just reword symbol names or repeat type declarations - Use **JSDoc** formatting for comments (not TSDoc or inline comments) ### Logging - Just use `console` for logging. ### Testing #### Unit Testing - **All unit tests should use Vitest** - DO NOT attempt to install or use other testing libraries like Jest - Test files should be named `[target].test.ts` and placed in the same directory as the code they are testing (NOT a separate directory) - GOOD example: `src/my-file.ts` and `src/my-file.test.ts` - BAD example: `src/my-file.ts` and `src/test/my-file.test.ts` or `test/my-file.test.ts` or `src/__tests__/my-file.test.ts` - Tests should be run with `pnpm test:unit` - You may use `any`/`unknown` in test files (such as `*.test.ts`) or test fixtures (like `**/test-data.ts`) to facilitate mocking or stubbing external modules or partial function arguments, referencing the usage guidelines in the TypeScript section. - Frontend react code does not need unit tests #### Test Coverage - Test critical business logic and edge cases - Don't add tests for trivial code or just to increase test coverage - Don't make tests too brittle or flaky by relying on implementation details ### Git - When possible, combine the `git add` and `git commit` commands into a single `git commit -am` command, to speed things up ================================================ FILE: Tiltfile ================================================ # 🌊 run `tilt up` to start # then open http://localhost:10350/r/(all)/overview load('ext://uibutton', 'cmd_button', 'bool_input', 'location') # Find docs on Tilt at https://docs.tilt.dev/api.html#api.local_resource # Find icons at https://fonts.google.com/icons local_resource( '🍍 API', serve_dir='apps/api', serve_cmd='pnpm dev:server', links=[ link('http://localhost:3001/v1/health', 'API'), ], labels=['Agentic'] ) local_resource( '🌶️ Web', serve_dir='apps/web', serve_cmd='pnpm dev', links=[ link('http://localhost:3000', 'Web'), ], labels=['Agentic'] ) local_resource( '🍉 Gateway', serve_dir='apps/gateway', serve_cmd='pnpm dev', labels=['Agentic'] ) local_resource( '🧪 E2E Tests', cmd='echo 0', labels=['Testing'], auto_init=False ) local_resource( '🔍 Drizzle Studio', serve_dir='apps/api', serve_cmd='pnpm drizzle-kit studio', links=[ link('https://local.drizzle.studio', 'Drizzle Studio'), ], labels=['Services'], ) local_resource( '💸 Stripe Webhooks', serve_dir='apps/api', serve_cmd='pnpm dev:stripe', # links=[ link('http://localhost:4983', 'Stripe Webhooks'), ], labels=['Services'], ) cmd_button( 'Seed Database', argv=['sh', '-c', 'cd apps/e2e && pnpm run seed-db'], location=location.NAV, icon_name='nature', text='Seed Database', ) ================================================ FILE: apps/api/drizzle.config.ts ================================================ import { defineConfig } from 'drizzle-kit' export default defineConfig({ out: './drizzle', schema: './src/db/schema/index.ts', dialect: 'postgresql', dbCredentials: { // eslint-disable-next-line no-process-env url: process.env.DATABASE_URL! } }) ================================================ FILE: apps/api/package.json ================================================ { "name": "api", "private": true, "version": "8.4.4", "description": "Internal Agentic platform API service.", "author": "Travis Fischer ", "license": "AGPL-3.0", "repository": { "type": "git", "url": "git+https://github.com/transitive-bullshit/agentic.git", "directory": "apps/api" }, "type": "module", "source": "./src/server.ts", "scripts": { "build": "tsup", "dev": "run-p dev:*", "dev:server": "dotenvx run -- tsx src/server.ts", "dev:stripe": "dotenvx run -- stripe listen --forward-to http://localhost:3001/v1/webhooks/stripe", "prod": "run-p prod:*", "prod:server": "dotenvx run -o -f .env.production -- tsx src/server.ts", "prod:stripe": "dotenvx run -o -f .env.production -- stripe listen --forward-to http://localhost:3001/v1/webhooks/stripe", "start": "tsx src/server.ts", "drizzle-kit": "dotenvx run -- drizzle-kit", "drizzle-kit:prod": "dotenvx run -o -f .env.production -- drizzle-kit", "clean": "del dist", "test": "run-s test:*", "test:typecheck": "tsc --noEmit", "test:unit": "[ -n \"$CI\" ] || dotenvx run -- vitest run" }, "dependencies": { "@agentic/platform": "workspace:*", "@agentic/platform-core": "workspace:*", "@agentic/platform-emails": "workspace:*", "@agentic/platform-hono": "workspace:*", "@agentic/platform-types": "workspace:*", "@agentic/platform-validators": "workspace:*", "@aws-sdk/client-s3": "^3.726.1", "@aws-sdk/s3-request-presigner": "^3.726.1", "@dicebear/collection": "catalog:", "@dicebear/core": "catalog:", "@fisch0920/drizzle-orm": "catalog:", "@fisch0920/drizzle-zod": "catalog:", "@hono/node-server": "catalog:", "@hono/zod-openapi": "catalog:", "@paralleldrive/cuid2": "catalog:", "@sentry/node": "catalog:", "bcryptjs": "catalog:", "exit-hook": "catalog:", "file-type": "^21.0.0", "hono": "catalog:", "ky": "catalog:", "mrmime": "^2.0.1", "octokit": "catalog:", "p-all": "catalog:", "postgres": "catalog:", "restore-cursor": "catalog:", "semver": "catalog:", "stripe": "catalog:", "type-fest": "catalog:", "zod-validation-error": "catalog:" }, "devDependencies": { "@types/semver": "catalog:", "drizzle-kit": "catalog:", "drizzle-orm": "catalog:" } } ================================================ FILE: apps/api/readme.md ================================================

Agentic

Build Status Prettier Code Formatting

# Agentic API > Backend API for the Agentic platform. - [Website](https://agentic.so) - [Docs](https://docs.agentic.so) ## Dependencies - **Postgres** - `DATABASE_URL` - Postgres connection string - [On macOS](https://wiki.postgresql.org/wiki/Homebrew): `brew install postgresql && brew services start postgresql` - You'll need to run `pnpm drizzle-kit push` to set up your database schema - **S3** - Required to use file attachments - Any S3-compatible provider is supported, such as [Cloudflare R2](https://developers.cloudflare.com/r2/) - Alterantively, you can use a local S3 server like [MinIO](https://github.com/minio/minio#homebrew-recommended) or [LocalStack](https://github.com/localstack/localstack) - To run LocalStack on macOS: `brew install localstack/tap/localstack-cli && localstack start -d` - To run MinIO macOS: `brew install minio/stable/minio && minio server /data` - I recommend using Cloudflare R2, though – it's amazing and should be free for most use cases! - `S3_BUCKET` - Required - `S3_REGION` - Optional; defaults to `auto` - `S3_ENDPOINT` - Required; example: `https://.r2.cloudflarestorage.com` - `ACCESS_KEY_ID` - Required ([cloudflare R2 docs](https://developers.cloudflare.com/r2/api/s3/tokens/)) - `SECRET_ACCESS_KEY` - Required ([cloudflare R2 docs](https://developers.cloudflare.com/r2/api/s3/tokens/)) ## License [GNU AGPL 3.0](https://choosealicense.com/licenses/agpl-3.0/) ================================================ FILE: apps/api/src/api-v1/auth/github-callback.ts ================================================ import type { DefaultHonoEnv } from '@agentic/platform-hono' import type { OpenAPIHono } from '@hono/zod-openapi' import { assert } from '@agentic/platform-core' import { authStorage } from './utils' export function registerV1GitHubOAuthCallback( app: OpenAPIHono ) { return app.get('auth/github/callback', async (c) => { const logger = c.get('logger') const query = c.req.query() assert(query.state, 400, 'State is required') const entry = await authStorage.get(['github', query.state, 'redirectUri']) assert(entry, 400, 'Redirect URI not found') const redirectUri = entry.redirectUri assert(entry.redirectUri, 400, 'Redirect URI not found') const url = new URL(redirectUri) for (const [key, value] of Object.entries(query)) { url.searchParams.set(key, value) } logger.info('GitHub auth callback', query, '=>', url.toString(), { rawUrl: redirectUri, query }) return c.redirect(url.toString()) }) } ================================================ FILE: apps/api/src/api-v1/auth/github-exchange.ts ================================================ import type { DefaultHonoEnv } from '@agentic/platform-hono' import { assert, parseZodSchema } from '@agentic/platform-core' import { createRoute, type OpenAPIHono, z } from '@hono/zod-openapi' import { createAuthToken } from '@/lib/auth/create-auth-token' import { upsertOrLinkUserAccount } from '@/lib/auth/upsert-or-link-user-account' import { exchangeGitHubOAuthCodeForAccessToken, getGitHubClient } from '@/lib/external/github' import { openapiAuthenticatedSecuritySchemas, openapiErrorResponse404, openapiErrorResponses } from '@/lib/openapi-utils' import { authSessionResponseSchema } from './schemas' const route = createRoute({ description: 'Exchanges a GitHub OAuth code for an Agentic auth session.', tags: ['auth'], operationId: 'exchangeOAuthCodeWithGitHub', method: 'post', path: 'auth/github/exchange', security: openapiAuthenticatedSecuritySchemas, request: { body: { required: true, content: { 'application/json': { schema: z .object({ code: z.string() }) .passthrough() } } } }, responses: { 200: { description: 'An auth session', content: { 'application/json': { schema: authSessionResponseSchema } } }, ...openapiErrorResponses, ...openapiErrorResponse404 } }) export function registerV1GitHubOAuthExchange( app: OpenAPIHono ) { return app.openapi(route, async (c) => { const logger = c.get('logger') const body = c.req.valid('json') const result = await exchangeGitHubOAuthCodeForAccessToken(body) logger.info('github oauth', result) const client = getGitHubClient({ accessToken: result.access_token! }) const { data: ghUser } = await client.rest.users.getAuthenticated() logger.info('github user', ghUser) if (!ghUser.email) { const { data: emails } = await client.request('GET /user/emails') const primary = emails.find((e) => e.primary) const verified = emails.find((e) => e.verified) const fallback = emails.find((e) => e.email) const email = primary?.email || verified?.email || fallback?.email ghUser.email = email! } assert( ghUser.email, 'Error authenticating with GitHub: user email is required.' ) const now = Date.now() const user = await upsertOrLinkUserAccount({ partialAccount: { provider: 'github', accountId: `${ghUser.id}`, accountUsername: ghUser.login.toLowerCase(), accessToken: result.access_token, refreshToken: result.refresh_token, // `expires_in` and `refresh_token_expires_in` are given in seconds accessTokenExpiresAt: result.expires_in ? new Date(now + result.expires_in * 1000) : undefined, refreshTokenExpiresAt: result.refresh_token_expires_in ? new Date(now + result.refresh_token_expires_in * 1000) : undefined, scope: result.scope || undefined }, partialUser: { email: ghUser.email, isEmailVerified: true, name: ghUser.name || undefined, username: ghUser.login.toLowerCase(), image: ghUser.avatar_url } }) logger.info('github user result', user) const token = await createAuthToken(user) return c.json(parseZodSchema(authSessionResponseSchema, { token, user })) }) } ================================================ FILE: apps/api/src/api-v1/auth/github-init.ts ================================================ import type { DefaultHonoEnv } from '@agentic/platform-hono' import { createRoute, type OpenAPIHono, z } from '@hono/zod-openapi' import { env } from '@/lib/env' import { openapiAuthenticatedSecuritySchemas, openapiErrorResponse404, openapiErrorResponses } from '@/lib/openapi-utils' import { authStorage } from './utils' const route = createRoute({ description: 'Starts a GitHub OAuth flow.', tags: ['auth'], operationId: 'initGitHubOAuthFlow', method: 'get', path: 'auth/github/init', security: openapiAuthenticatedSecuritySchemas, request: { query: z .object({ redirect_uri: z.string(), client_id: z.string().optional(), scope: z.string().optional() }) .passthrough() }, responses: { 302: { description: 'Redirected to GitHub' }, ...openapiErrorResponses, ...openapiErrorResponse404 } }) export function registerV1GitHubOAuthInitFlow( app: OpenAPIHono ) { return app.openapi(route, async (c) => { const logger = c.get('logger') const { client_id: clientId = env.GITHUB_CLIENT_ID, scope = 'user:email', redirect_uri: redirectUri } = c.req.query() const state = crypto.randomUUID() // TODO: unique identifier! // TODO: THIS IS IMPORTANT!! if multiple users are authenticating with github concurrently, this will currently really mess things up... await authStorage.set(['github', state, 'redirectUri'], { redirectUri }) const publicRedirectUri = `${env.apiBaseUrl}/v1/auth/github/callback` const url = new URL('https://github.com/login/oauth/authorize') url.searchParams.append('client_id', clientId) url.searchParams.append('scope', scope) url.searchParams.append('state', state) url.searchParams.append('redirect_uri', publicRedirectUri) logger.info('Redirecting to GitHub', { url: url.toString(), clientId, scope, publicRedirectUri }) return c.redirect(url.toString()) }) } ================================================ FILE: apps/api/src/api-v1/auth/schemas.ts ================================================ import { z } from '@hono/zod-openapi' import { schema } from '@/db' export const authSessionResponseSchema = z .object({ token: z.string().nonempty(), user: schema.userSelectSchema }) .openapi('AuthSession') ================================================ FILE: apps/api/src/api-v1/auth/sign-in-with-password.ts ================================================ import type { DefaultHonoEnv } from '@agentic/platform-hono' import type { Context } from 'hono' import { assert, parseZodSchema } from '@agentic/platform-core' import { isValidPassword } from '@agentic/platform-validators' import { createRoute, type OpenAPIHono, z } from '@hono/zod-openapi' import { compare } from 'bcryptjs' import { and, db, eq, schema } from '@/db' import { createAuthToken } from '@/lib/auth/create-auth-token' import { openapiAuthenticatedSecuritySchemas, openapiErrorResponse404, openapiErrorResponses } from '@/lib/openapi-utils' import { authSessionResponseSchema } from './schemas' const route = createRoute({ description: 'Signs in with email and password.', tags: ['auth'], operationId: 'signInWithPassword', method: 'post', path: 'auth/password/signin', security: openapiAuthenticatedSecuritySchemas, request: { body: { required: true, content: { 'application/json': { schema: z.object({ email: z.string().email(), password: z.string().refine((password) => isValidPassword(password)) }) } } } }, responses: { 200: { description: 'An auth session', content: { 'application/json': { schema: authSessionResponseSchema } } }, ...openapiErrorResponses, ...openapiErrorResponse404 } }) export function registerV1SignInWithPassword(app: OpenAPIHono) { return app.openapi(route, trySignIn) } export async function trySignIn( c: Context< DefaultHonoEnv, 'auth/password/signin', { in: { json: { password: string email: string } } out: { json: { password: string email: string } } } > ) { const { email, password } = c.req.valid('json') const user = await db.query.users.findFirst({ where: eq(schema.users.email, email) }) assert(user, 404, `User not found "${email}"`) const account = await db.query.accounts.findFirst({ where: and( eq(schema.accounts.userId, user.id), eq(schema.accounts.provider, 'password') ) }) assert(account?.password, 404, `User "${email}" does not have a password set`) assert(compare(password, account.password), 403, 'Authentication error') const token = await createAuthToken(user) return c.json(parseZodSchema(authSessionResponseSchema, { token, user })) } ================================================ FILE: apps/api/src/api-v1/auth/sign-up-with-password.ts ================================================ import type { DefaultHonoEnv } from '@agentic/platform-hono' import { parseZodSchema } from '@agentic/platform-core' import { isValidPassword } from '@agentic/platform-validators' import { createRoute, type OpenAPIHono, z } from '@hono/zod-openapi' import { genSalt, hash } from 'bcryptjs' import { usernameSchema } from '@/db' import { createAuthToken } from '@/lib/auth/create-auth-token' import { upsertOrLinkUserAccount } from '@/lib/auth/upsert-or-link-user-account' import { ensureUniqueNamespace } from '@/lib/ensure-unique-namespace' import { openapiAuthenticatedSecuritySchemas, openapiErrorResponse404, openapiErrorResponses } from '@/lib/openapi-utils' import { authSessionResponseSchema } from './schemas' import { trySignIn } from './sign-in-with-password' const route = createRoute({ description: 'Signs up for a new account with email and password.', tags: ['auth'], operationId: 'signUpWithPassword', method: 'post', path: 'auth/password/signup', security: openapiAuthenticatedSecuritySchemas, request: { body: { required: true, content: { 'application/json': { schema: z.object({ username: usernameSchema, email: z.string().email(), password: z.string().refine((password) => isValidPassword(password)) }) } } } }, responses: { 200: { description: 'An auth session', content: { 'application/json': { schema: authSessionResponseSchema } } }, ...openapiErrorResponses, ...openapiErrorResponse404 } }) export function registerV1SignUpWithPassword(app: OpenAPIHono) { return app.openapi(route, async (c) => { try { // try signing in to see if the user already exists return await trySignIn(c) } catch { // Ignore errors } const { username, email, password } = c.req.valid('json') await ensureUniqueNamespace(username, { label: 'username' }) const salt = await genSalt() const hashedPassword = await hash(password, salt) // TODO: fail if username is taken const user = await upsertOrLinkUserAccount({ partialAccount: { provider: 'password', accountId: email, password: hashedPassword }, partialUser: { username, email } }) const token = await createAuthToken(user) return c.json(parseZodSchema(authSessionResponseSchema, { token, user })) }) } ================================================ FILE: apps/api/src/api-v1/auth/utils.ts ================================================ import { DrizzleAuthStorage } from '@/lib/auth/drizzle-auth-storage' export const authStorage = DrizzleAuthStorage() ================================================ FILE: apps/api/src/api-v1/consumers/admin-activate-consumer.ts ================================================ import { assert, parseZodSchema } from '@agentic/platform-core' import { createRoute, type OpenAPIHono } from '@hono/zod-openapi' import type { AuthenticatedHonoEnv } from '@/lib/types' import { db, eq, schema } from '@/db' import { aclAdmin } from '@/lib/acl-admin' import { openapiAuthenticatedSecuritySchemas, openapiErrorResponse404, openapiErrorResponse409, openapiErrorResponse410, openapiErrorResponses } from '@/lib/openapi-utils' import { consumerIdParamsSchema } from './schemas' import { setAdminCacheControlForConsumer } from './utils' const route = createRoute({ description: "Activates a consumer signifying that at least one API call has been made using the consumer's API token. This method is idempotent and admin-only.", tags: ['admin', 'consumers'], operationId: 'adminActivateConsumer', method: 'put', path: 'admin/consumers/{consumerId}/activate', security: openapiAuthenticatedSecuritySchemas, request: { params: consumerIdParamsSchema }, responses: { 200: { description: 'An admin consumer object', content: { 'application/json': { schema: schema.consumerAdminSelectSchema } } }, ...openapiErrorResponses, ...openapiErrorResponse404, ...openapiErrorResponse409, ...openapiErrorResponse410 } }) export function registerV1AdminActivateConsumer( app: OpenAPIHono ) { return app.openapi(route, async (c) => { const { consumerId } = c.req.valid('param') await aclAdmin(c) const [consumer] = await db .update(schema.consumers) .set({ activated: true }) .where(eq(schema.consumers.id, consumerId)) .returning() assert(consumer, 404, `Consumer not found "${consumerId}"`) setAdminCacheControlForConsumer(c, consumer) return c.json(parseZodSchema(schema.consumerAdminSelectSchema, consumer)) }) } ================================================ FILE: apps/api/src/api-v1/consumers/admin-get-consumer-by-api-key.ts ================================================ import { assert, parseZodSchema } from '@agentic/platform-core' import { createRoute, type OpenAPIHono } from '@hono/zod-openapi' import type { AuthenticatedHonoEnv } from '@/lib/types' import { db, eq, schema } from '@/db' import { aclAdmin } from '@/lib/acl-admin' import { openapiAuthenticatedSecuritySchemas, openapiErrorResponse404, openapiErrorResponses } from '@/lib/openapi-utils' import { consumerApiKeyParamsSchema, populateConsumerSchema } from './schemas' import { setAdminCacheControlForConsumer } from './utils' const route = createRoute({ description: 'Gets a consumer by API key. This route is admin-only.', tags: ['admin', 'consumers'], operationId: 'adminGetConsumerByApiKey', method: 'get', // TODO: is it wise to use a path param for the API key? especially wehn it'll // be cached in cloudflare's shared cache? path: 'admin/consumers/api-keys/{apiKey}', security: openapiAuthenticatedSecuritySchemas, request: { params: consumerApiKeyParamsSchema, query: populateConsumerSchema }, responses: { 200: { description: 'An admin consumer object', content: { 'application/json': { schema: schema.consumerAdminSelectSchema } } }, ...openapiErrorResponses, ...openapiErrorResponse404 } }) export function registerV1AdminGetConsumerByApiKey( app: OpenAPIHono ) { return app.openapi(route, async (c) => { const { apiKey } = c.req.valid('param') const { populate = [] } = c.req.valid('query') await aclAdmin(c) const consumer = await db.query.consumers.findFirst({ where: eq(schema.consumers.token, apiKey), with: { ...Object.fromEntries(populate.map((field) => [field, true])) } }) assert(consumer, 404, `API key not found "${apiKey}"`) setAdminCacheControlForConsumer(c, consumer) return c.json(parseZodSchema(schema.consumerAdminSelectSchema, consumer)) }) } ================================================ FILE: apps/api/src/api-v1/consumers/create-billing-portal-session.ts ================================================ import { createRoute, type OpenAPIHono, z } from '@hono/zod-openapi' import type { AuthenticatedHonoEnv } from '@/lib/types' import { upsertStripeCustomer } from '@/lib/billing/upsert-stripe-customer' import { env } from '@/lib/env' import { stripe } from '@/lib/external/stripe' import { openapiAuthenticatedSecuritySchemas, openapiErrorResponse404, openapiErrorResponse409, openapiErrorResponse410, openapiErrorResponses } from '@/lib/openapi-utils' const route = createRoute({ description: 'Creates a Stripe billing portal session for the authenticated user.', tags: ['consumers'], operationId: 'createBillingPortalSession', method: 'post', path: 'consumers/billing-portal', security: openapiAuthenticatedSecuritySchemas, responses: { 200: { description: 'A billing portal session URL', content: { 'application/json': { schema: z.object({ url: z.string().url() }) } } }, ...openapiErrorResponses, ...openapiErrorResponse404, ...openapiErrorResponse409, ...openapiErrorResponse410 } }) export function registerV1CreateBillingPortalSession( app: OpenAPIHono ) { return app.openapi(route, async (c) => { const { stripeCustomer } = await upsertStripeCustomer(c) const portalSession = await stripe.billingPortal.sessions.create({ customer: stripeCustomer.id, return_url: `${env.AGENTIC_WEB_BASE_URL}/app/consumers` }) return c.json({ url: portalSession.url }) }) } ================================================ FILE: apps/api/src/api-v1/consumers/create-consumer-billing-portal-session.ts ================================================ import { assert } from '@agentic/platform-core' import { createRoute, type OpenAPIHono, z } from '@hono/zod-openapi' import type { AuthenticatedHonoEnv } from '@/lib/types' import { db, eq, schema } from '@/db' import { acl } from '@/lib/acl' import { env } from '@/lib/env' import { stripe } from '@/lib/external/stripe' import { openapiAuthenticatedSecuritySchemas, openapiErrorResponse404, openapiErrorResponse409, openapiErrorResponse410, openapiErrorResponses } from '@/lib/openapi-utils' import { consumerIdParamsSchema } from './schemas' const route = createRoute({ description: 'Creates a Stripe billing portal session for a customer.', tags: ['consumers'], operationId: 'createConsumerBillingPortalSession', method: 'post', path: 'consumers/{consumerId}/billing-portal', security: openapiAuthenticatedSecuritySchemas, request: { params: consumerIdParamsSchema }, responses: { 200: { description: 'A billing portal session URL', content: { 'application/json': { schema: z.object({ url: z.string().url() }) } } }, ...openapiErrorResponses, ...openapiErrorResponse404, ...openapiErrorResponse409, ...openapiErrorResponse410 } }) export function registerV1CreateConsumerBillingPortalSession( app: OpenAPIHono ) { return app.openapi(route, async (c) => { const { consumerId } = c.req.valid('param') const consumer = await db.query.consumers.findFirst({ where: eq(schema.consumers.id, consumerId) }) assert(consumer, 404, `Consumer not found "${consumerId}"`) await acl(c, consumer, { label: 'Consumer' }) const portalSession = await stripe.billingPortal.sessions.create({ customer: consumer._stripeCustomerId, return_url: `${env.AGENTIC_WEB_BASE_URL}/app/consumers/${consumerId}` }) return c.json({ url: portalSession.url }) }) } ================================================ FILE: apps/api/src/api-v1/consumers/create-consumer-checkout-session.ts ================================================ import { parseZodSchema, pick } from '@agentic/platform-core' import { createRoute, type OpenAPIHono, z } from '@hono/zod-openapi' import type { AuthenticatedHonoEnv } from '@/lib/types' import { schema } from '@/db' import { upsertConsumerStripeCheckout } from '@/lib/consumers/upsert-consumer-stripe-checkout' import { openapiAuthenticatedSecuritySchemas, openapiErrorResponse404, openapiErrorResponse409, openapiErrorResponse410, openapiErrorResponses } from '@/lib/openapi-utils' const route = createRoute({ description: 'Creates a Stripe checkout session for a consumer to modify their subscription to a project.', tags: ['consumers'], operationId: 'createConsumerCheckoutSession', method: 'post', path: 'consumers/checkout', security: openapiAuthenticatedSecuritySchemas, request: { body: { required: true, content: { 'application/json': { schema: schema.consumerInsertSchema } } } }, responses: { 200: { description: 'A consumer object', content: { 'application/json': { schema: z.object({ checkoutSession: z.object({ id: z.string(), url: z.string().url() }), consumer: schema.consumerSelectSchema }) } } }, ...openapiErrorResponses, ...openapiErrorResponse404, ...openapiErrorResponse409, ...openapiErrorResponse410 } }) export function registerV1CreateConsumerCheckoutSession( app: OpenAPIHono ) { return app.openapi(route, async (c) => { const body = c.req.valid('json') const { checkoutSession, consumer } = await upsertConsumerStripeCheckout( c, body ) return c.json({ checkoutSession: pick(checkoutSession, 'id', 'url'), consumer: parseZodSchema(schema.consumerSelectSchema, consumer) }) }) } ================================================ FILE: apps/api/src/api-v1/consumers/create-consumer.ts ================================================ import { parseZodSchema } from '@agentic/platform-core' import { createRoute, type OpenAPIHono } from '@hono/zod-openapi' import type { AuthenticatedHonoEnv } from '@/lib/types' import { schema } from '@/db' import { upsertConsumer } from '@/lib/consumers/upsert-consumer' import { openapiAuthenticatedSecuritySchemas, openapiErrorResponse404, openapiErrorResponse409, openapiErrorResponse410, openapiErrorResponses } from '@/lib/openapi-utils' const route = createRoute({ description: "Upserts a consumer by modifying a customer's subscription to a project.", tags: ['consumers'], operationId: 'createConsumer', method: 'post', path: 'consumers', security: openapiAuthenticatedSecuritySchemas, request: { body: { required: true, content: { 'application/json': { schema: schema.consumerInsertSchema } } } }, responses: { 200: { description: 'A consumer object', content: { 'application/json': { schema: schema.consumerSelectSchema } } }, ...openapiErrorResponses, ...openapiErrorResponse404, ...openapiErrorResponse409, ...openapiErrorResponse410 } }) export function registerV1CreateConsumer( app: OpenAPIHono ) { return app.openapi(route, async (c) => { const body = c.req.valid('json') const consumer = await upsertConsumer(c, body) return c.json(parseZodSchema(schema.consumerSelectSchema, consumer)) }) } ================================================ FILE: apps/api/src/api-v1/consumers/get-consumer-by-project-identifier.ts ================================================ import { assert, parseZodSchema } from '@agentic/platform-core' import { createRoute, type OpenAPIHono } from '@hono/zod-openapi' import type { AuthenticatedHonoEnv } from '@/lib/types' import { and, db, eq, schema } from '@/db' import { acl } from '@/lib/acl' import { aclPublicProject } from '@/lib/acl-public-project' import { openapiAuthenticatedSecuritySchemas, openapiErrorResponse404, openapiErrorResponses } from '@/lib/openapi-utils' import { projectIdentifierAndPopulateConsumerSchema } from './schemas' const route = createRoute({ description: 'Gets a consumer for the authenticated user and the given project identifier.', tags: ['consumers'], operationId: 'getConsumerByProjectIdentifier', method: 'get', path: 'consumers/by-project-identifier', security: openapiAuthenticatedSecuritySchemas, request: { query: projectIdentifierAndPopulateConsumerSchema }, responses: { 200: { description: 'A consumer object', content: { 'application/json': { schema: schema.consumerSelectSchema } } }, ...openapiErrorResponses, ...openapiErrorResponse404 } }) export function registerV1GetConsumerByProjectIdentifier( app: OpenAPIHono ) { return app.openapi(route, async (c) => { const { projectIdentifier, populate = [] } = c.req.valid('query') const userId = c.get('userId') const project = await db.query.projects.findFirst({ where: eq(schema.projects.identifier, projectIdentifier) }) assert(project, 404, `Project not found "${projectIdentifier}"`) aclPublicProject(project) const consumer = await db.query.consumers.findFirst({ where: and( eq(schema.consumers.userId, userId), eq(schema.consumers.projectId, project.id) ), with: { ...Object.fromEntries(populate.map((field) => [field, true])) } }) assert( consumer, 404, `Consumer not found for user "${userId}" and project "${projectIdentifier}"` ) await acl(c, consumer, { label: 'Consumer' }) return c.json(parseZodSchema(schema.consumerSelectSchema, consumer)) }) } ================================================ FILE: apps/api/src/api-v1/consumers/get-consumer.ts ================================================ import { assert, parseZodSchema } from '@agentic/platform-core' import { createRoute, type OpenAPIHono } from '@hono/zod-openapi' import type { AuthenticatedHonoEnv } from '@/lib/types' import { db, eq, schema } from '@/db' import { acl } from '@/lib/acl' import { openapiAuthenticatedSecuritySchemas, openapiErrorResponse404, openapiErrorResponses } from '@/lib/openapi-utils' import { consumerIdParamsSchema, populateConsumerSchema } from './schemas' const route = createRoute({ description: 'Gets a consumer by ID.', tags: ['consumers'], operationId: 'getConsumer', method: 'get', path: 'consumers/{consumerId}', security: openapiAuthenticatedSecuritySchemas, request: { params: consumerIdParamsSchema, query: populateConsumerSchema }, responses: { 200: { description: 'A consumer object', content: { 'application/json': { schema: schema.consumerSelectSchema } } }, ...openapiErrorResponses, ...openapiErrorResponse404 } }) export function registerV1GetConsumer(app: OpenAPIHono) { return app.openapi(route, async (c) => { const { consumerId } = c.req.valid('param') const { populate = [] } = c.req.valid('query') const consumer = await db.query.consumers.findFirst({ where: eq(schema.consumers.id, consumerId), with: { ...Object.fromEntries(populate.map((field) => [field, true])) } }) assert(consumer, 404, `Consumer not found "${consumerId}"`) await acl(c, consumer, { label: 'Consumer' }) return c.json(parseZodSchema(schema.consumerSelectSchema, consumer)) }) } ================================================ FILE: apps/api/src/api-v1/consumers/list-consumers.ts ================================================ import { parseZodSchema } from '@agentic/platform-core' import { createRoute, type OpenAPIHono, z } from '@hono/zod-openapi' import type { AuthenticatedHonoEnv } from '@/lib/types' import { db, eq, schema } from '@/db' import { ensureAuthUser } from '@/lib/ensure-auth-user' import { openapiAuthenticatedSecuritySchemas, openapiErrorResponse404, openapiErrorResponses } from '@/lib/openapi-utils' import { paginationAndPopulateConsumerSchema } from './schemas' const route = createRoute({ description: 'Lists all of the customer subscriptions for the current user.', tags: ['consumers'], operationId: 'listConsumers', method: 'get', path: 'consumers', security: openapiAuthenticatedSecuritySchemas, request: { query: paginationAndPopulateConsumerSchema }, responses: { 200: { description: 'A list of consumers', content: { 'application/json': { schema: z.array(schema.consumerSelectSchema) } } }, ...openapiErrorResponses, ...openapiErrorResponse404 } }) export function registerV1ListConsumers( app: OpenAPIHono ) { return app.openapi(route, async (c) => { const { offset = 0, limit = 10, sort = 'desc', sortBy = 'createdAt', populate = [] } = c.req.valid('query') const user = await ensureAuthUser(c) const consumers = await db.query.consumers.findMany({ where: eq(schema.consumers.userId, user.id), with: { ...Object.fromEntries(populate.map((field) => [field, true])) }, orderBy: (consumers, { asc, desc }) => [ sort === 'desc' ? desc(consumers[sortBy]) : asc(consumers[sortBy]) ], offset, limit }) return c.json( parseZodSchema(z.array(schema.consumerSelectSchema), consumers) ) }) } ================================================ FILE: apps/api/src/api-v1/consumers/list-project-consumers.ts ================================================ import { assert, parseZodSchema } from '@agentic/platform-core' import { createRoute, type OpenAPIHono, z } from '@hono/zod-openapi' import type { AuthenticatedHonoEnv } from '@/lib/types' import { db, eq, schema } from '@/db' import { acl } from '@/lib/acl' import { openapiAuthenticatedSecuritySchemas, openapiErrorResponse404, openapiErrorResponses } from '@/lib/openapi-utils' import { projectIdParamsSchema } from '../projects/schemas' import { paginationAndPopulateConsumerSchema } from './schemas' const route = createRoute({ description: 'Lists all of the customers for a project.', tags: ['consumers'], operationId: 'listConsumersForProject', method: 'get', path: 'projects/{projectId}/consumers', security: openapiAuthenticatedSecuritySchemas, request: { params: projectIdParamsSchema, query: paginationAndPopulateConsumerSchema }, responses: { 200: { description: 'A list of consumers subscribed to the given project', content: { 'application/json': { schema: z.array(schema.consumerSelectSchema) } } }, ...openapiErrorResponses, ...openapiErrorResponse404 } }) export function registerV1ListConsumersForProject( app: OpenAPIHono ) { return app.openapi(route, async (c) => { const { offset = 0, limit = 10, sort = 'desc', sortBy = 'createdAt', populate = [] } = c.req.valid('query') const { projectId } = c.req.valid('param') assert(projectId, 400, 'Project ID is required') const project = await db.query.projects.findFirst({ where: eq(schema.projects.id, projectId) }) assert(project, 404, `Project not found "${projectId}"`) await acl(c, project, { label: 'Project' }) const consumers = await db.query.consumers.findMany({ where: eq(schema.consumers.projectId, projectId), with: { ...Object.fromEntries(populate.map((field) => [field, true])) }, orderBy: (consumers, { asc, desc }) => [ sort === 'desc' ? desc(consumers[sortBy]) : asc(consumers[sortBy]) ], offset, limit }) return c.json( parseZodSchema(z.array(schema.consumerSelectSchema), consumers) ) }) } ================================================ FILE: apps/api/src/api-v1/consumers/refresh-consumer-api-key.ts ================================================ import { assert, parseZodSchema } from '@agentic/platform-core' import { createRoute, type OpenAPIHono } from '@hono/zod-openapi' import type { AuthenticatedHonoEnv } from '@/lib/types' import { db, eq, schema } from '@/db' import { acl } from '@/lib/acl' import { createConsumerApiKey } from '@/lib/create-consumer-api-key' import { openapiAuthenticatedSecuritySchemas, openapiErrorResponse404, openapiErrorResponses } from '@/lib/openapi-utils' import { consumerIdParamsSchema } from './schemas' const route = createRoute({ description: "Refreshes a consumer's API key.", tags: ['consumers'], operationId: 'refreshConsumerApiKey', method: 'post', path: 'consumers/{consumerId}/refresh-api-key', security: openapiAuthenticatedSecuritySchemas, request: { params: consumerIdParamsSchema }, responses: { 200: { description: 'A consumer object', content: { 'application/json': { schema: schema.consumerSelectSchema } } }, ...openapiErrorResponses, ...openapiErrorResponse404 } }) export function registerV1RefreshConsumerApiKey( app: OpenAPIHono ) { return app.openapi(route, async (c) => { const { consumerId } = c.req.valid('param') let consumer = await db.query.consumers.findFirst({ where: eq(schema.consumers.id, consumerId) }) assert(consumer, 404, 'Consumer not found') await acl(c, consumer, { label: 'Consumer' }) // Update the consumer's API token ;[consumer] = await db .update(schema.consumers) .set({ token: await createConsumerApiKey() }) .where(eq(schema.consumers.id, consumer.id)) .returning() assert(consumer, 500, 'Error updating consumer') return c.json(parseZodSchema(schema.consumerSelectSchema, consumer)) }) } ================================================ FILE: apps/api/src/api-v1/consumers/schemas.ts ================================================ import { z } from '@hono/zod-openapi' import { consumerIdSchema, consumerRelationsSchema, paginationSchema, projectIdentifierSchema } from '@/db' export const consumerIdParamsSchema = z.object({ consumerId: consumerIdSchema.openapi({ param: { description: 'Consumer ID', name: 'consumerId', in: 'path' } }) }) export const consumerApiKeyParamsSchema = z.object({ apiKey: z .string() .nonempty() .openapi({ param: { description: 'Consumer API key', name: 'apiKey', in: 'path' } }) }) export const projectIdentifierQuerySchema = z.object({ projectIdentifier: projectIdentifierSchema }) export const populateConsumerSchema = z.object({ populate: z .union([consumerRelationsSchema, z.array(consumerRelationsSchema)]) .default([]) .transform((p) => (Array.isArray(p) ? p : [p])) .optional() }) export const paginationAndPopulateConsumerSchema = z.object({ ...paginationSchema.shape, ...populateConsumerSchema.shape }) export const projectIdentifierAndPopulateConsumerSchema = z.object({ ...projectIdentifierQuerySchema.shape, ...populateConsumerSchema.shape }) ================================================ FILE: apps/api/src/api-v1/consumers/update-consumer.ts ================================================ import { parseZodSchema } from '@agentic/platform-core' import { createRoute, type OpenAPIHono } from '@hono/zod-openapi' import type { AuthenticatedHonoEnv } from '@/lib/types' import { schema } from '@/db' import { upsertConsumer } from '@/lib/consumers/upsert-consumer' import { openapiAuthenticatedSecuritySchemas, openapiErrorResponse404, openapiErrorResponse409, openapiErrorResponse410, openapiErrorResponses } from '@/lib/openapi-utils' import { consumerIdParamsSchema } from './schemas' const route = createRoute({ description: "Updates a consumer's subscription to a different deployment or pricing plan. Set `plan` to undefined to cancel the subscription.", tags: ['consumers'], operationId: 'updateConsumer', method: 'post', path: 'consumers/{consumerId}', security: openapiAuthenticatedSecuritySchemas, request: { params: consumerIdParamsSchema, body: { required: true, content: { 'application/json': { schema: schema.consumerUpdateSchema } } } }, responses: { 200: { description: 'A consumer object', content: { 'application/json': { schema: schema.consumerSelectSchema } } }, ...openapiErrorResponses, ...openapiErrorResponse404, ...openapiErrorResponse409, ...openapiErrorResponse410 } }) export function registerV1UpdateConsumer( app: OpenAPIHono ) { return app.openapi(route, async (c) => { const { consumerId } = c.req.valid('param') const body = c.req.valid('json') const consumer = await upsertConsumer(c, { ...body, consumerId }) return c.json(parseZodSchema(schema.consumerSelectSchema, consumer)) }) } ================================================ FILE: apps/api/src/api-v1/consumers/utils.ts ================================================ import type { RawConsumer } from '@/db' import type { AuthenticatedHonoContext } from '@/lib/types' import { setPublicCacheControl } from '@/lib/cache-control' import { env } from '@/lib/env' export function setAdminCacheControlForConsumer( c: AuthenticatedHonoContext, consumer: RawConsumer ) { if ( consumer.plan === 'free' || !consumer.activated || !consumer.isStripeSubscriptionActive ) { // TODO: should we cache free-tier consumers for longer on prod? // We really don't want free tier customers to cause our backend API so // much traffic, but we'd also like for customers upgrading to a paid tier // to have a snappy, smooth experience – without having to wait for their // free tier subscription to expire from the cache. setPublicCacheControl(c.res, env.isProd ? '30s' : '10s') } else { // We don't want the gateway hitting our API too often, so cache active // customer subscriptions for longer in production setPublicCacheControl(c.res, env.isProd ? '30m' : '1m') } } ================================================ FILE: apps/api/src/api-v1/deployments/admin-get-deployment-by-identifier.ts ================================================ import { assert, parseZodSchema } from '@agentic/platform-core' import { createRoute, type OpenAPIHono } from '@hono/zod-openapi' import type { AuthenticatedHonoEnv } from '@/lib/types' import { schema } from '@/db' import { acl } from '@/lib/acl' import { aclAdmin } from '@/lib/acl-admin' import { setPublicCacheControl } from '@/lib/cache-control' import { tryGetDeploymentByIdentifier } from '@/lib/deployments/try-get-deployment-by-identifier' import { env } from '@/lib/env' import { openapiAuthenticatedSecuritySchemas, openapiErrorResponse404, openapiErrorResponses } from '@/lib/openapi-utils' import { deploymentIdentifierAndPopulateSchema } from './schemas' const route = createRoute({ description: 'Gets a deployment by its public identifier. This route is admin-only.', tags: ['admin', 'deployments'], operationId: 'adminGetDeploymentByIdentifier', method: 'get', path: 'admin/deployments/by-identifier', security: openapiAuthenticatedSecuritySchemas, request: { query: deploymentIdentifierAndPopulateSchema }, responses: { 200: { description: 'An admin deployment object', content: { 'application/json': { schema: schema.deploymentAdminSelectSchema } } }, ...openapiErrorResponses, ...openapiErrorResponse404 } }) export function registerV1AdminGetDeploymentByIdentifier( app: OpenAPIHono ) { return app.openapi(route, async (c) => { const { deploymentIdentifier, populate = [] } = c.req.valid('query') await aclAdmin(c) const { project, ...deployment } = await tryGetDeploymentByIdentifier(c, { deploymentIdentifier, with: { ...Object.fromEntries(populate.map((field) => [field, true])), project: true } }) assert(deployment, 404, `Deployment not found "${deploymentIdentifier}"`) assert( project, 404, `Project not found for deployment "${deploymentIdentifier}"` ) await acl(c, deployment, { label: 'Deployment' }) // TODO: ensure that the deployment's project is either public OR the // consumer has access to it? const hasPopulateProject = populate.includes('project') if (env.isProd) { // Published deployments are immutable, so cache them for longer in production setPublicCacheControl(c.res, deployment.published ? '1h' : '5m') } else { setPublicCacheControl(c.res, '10s') } return c.json( parseZodSchema(schema.deploymentAdminSelectSchema, { ...deployment, ...(hasPopulateProject ? { project } : {}), _secret: project._secret }) ) }) } ================================================ FILE: apps/api/src/api-v1/deployments/create-deployment.ts ================================================ import { resolveAgenticProjectConfig } from '@agentic/platform' import { assert, parseZodSchema, sha256, slugify } from '@agentic/platform-core' import { isValidDeploymentIdentifier, parseProjectIdentifier } from '@agentic/platform-validators' import { createRoute, type OpenAPIHono } from '@hono/zod-openapi' import type { AuthenticatedHonoEnv } from '@/lib/types' import { db, eq, schema } from '@/db' import { acl } from '@/lib/acl' import { normalizeDeploymentVersion } from '@/lib/deployments/normalize-deployment-version' import { publishDeployment } from '@/lib/deployments/publish-deployment' import { ensureAuthUser } from '@/lib/ensure-auth-user' import { env } from '@/lib/env' import { openapiAuthenticatedSecuritySchemas, openapiErrorResponse404, openapiErrorResponse409, openapiErrorResponses } from '@/lib/openapi-utils' import { uploadFileUrlToStorage } from '@/lib/storage' import { createDeploymentQuerySchema } from './schemas' const route = createRoute({ description: 'Creates a new deployment within a project.', tags: ['deployments'], operationId: 'createDeployment', method: 'post', path: 'deployments', security: openapiAuthenticatedSecuritySchemas, request: { query: createDeploymentQuerySchema, body: { required: true, content: { 'application/json': { schema: schema.deploymentInsertSchema } } } }, responses: { 200: { description: 'A deployment object', content: { 'application/json': { schema: schema.deploymentSelectSchema } } }, ...openapiErrorResponses, ...openapiErrorResponse404, ...openapiErrorResponse409 } }) export function registerV1CreateDeployment( app: OpenAPIHono ) { return app.openapi(route, async (c) => { const user = await ensureAuthUser(c) const { publish } = c.req.valid('query') const body = c.req.valid('json') const teamMember = c.get('teamMember') const logger = c.get('logger') const inputNamespace = teamMember ? teamMember.teamSlug : user.username const slug = body.slug ?? slugify(body.name) // TODO: don't duplicate this logic here const inputProjectIdentifier = `@${inputNamespace}/${slug}` const { projectIdentifier, projectNamespace, projectSlug } = parseProjectIdentifier(inputProjectIdentifier) let project = await db.query.projects.findFirst({ where: eq(schema.projects.identifier, projectIdentifier), with: { lastPublishedDeployment: true } }) if (!project) { // Used for testing e2e fixtures in the development marketplace const isPrivate = !( (user.username === 'dev' && env.isDev) || user.username === 'agentic' ) // Used to simplify recreating the demo `@agentic/search` project during // development while we're frequently resetting the database const secret = projectIdentifier === '@dev/search' || projectIdentifier === '@agentic/search' ? env.AGENTIC_SEARCH_PROXY_SECRET : await sha256() // Upsert the project if it doesn't already exist // The typecast is necessary here because we're not populating the // lastPublishedDeployment, but that's fine because it's a new project // so it will be empty anyway. project = ( await db .insert(schema.projects) .values({ name: body.name, identifier: projectIdentifier, namespace: projectNamespace, slug: projectSlug, userId: user.id, teamId: teamMember?.teamId, private: isPrivate, _secret: secret }) .returning() )[0] as typeof project } assert(project, 404, `Project not found "${projectIdentifier}"`) await acl(c, project, { label: 'Project' }) const projectId = project.id // TODO: investigate better short hash generation const hash = (await sha256()).slice(0, 8) const deploymentIdentifier = `${project.identifier}@${hash}` assert( isValidDeploymentIdentifier(deploymentIdentifier), 400, `Invalid deployment identifier "${deploymentIdentifier}"` ) let { version } = body if (publish) { assert( version, 400, `Deployment "version" field is required to publish deployment "${deploymentIdentifier}"` ) } if (version) { version = normalizeDeploymentVersion({ deploymentIdentifier, project, version }) } // Validate project config, including: // - pricing plans // - origin adapter config // - origin API base URL // - origin adapter OpenAPI or MCP specs // - tool definitions const agenticProjectConfig = await resolveAgenticProjectConfig(body, { label: `deployment "${deploymentIdentifier}"`, logger, uploadFileUrlToStorage: async (source) => { return uploadFileUrlToStorage(source, { prefix: projectIdentifier }) } }) // Create the deployment let [deployment] = await db .insert(schema.deployments) .values({ iconUrl: user.image, ...agenticProjectConfig, identifier: deploymentIdentifier, hash, userId: user.id, teamId: teamMember?.teamId, projectId, version }) .returning() assert( deployment, 500, `Failed to create deployment "${deploymentIdentifier}"` ) // Update the project await db .update(schema.projects) .set({ lastDeploymentId: deployment.id }) .where(eq(schema.projects.id, projectId)) if (publish) { deployment = await publishDeployment(c, { deployment, version: deployment.version! }) } return c.json(parseZodSchema(schema.deploymentSelectSchema, deployment)) }) } ================================================ FILE: apps/api/src/api-v1/deployments/get-deployment-by-identifier.ts ================================================ import { assert, parseZodSchema } from '@agentic/platform-core' import { createRoute, type OpenAPIHono } from '@hono/zod-openapi' import type { AuthenticatedHonoEnv } from '@/lib/types' import { schema } from '@/db' import { acl } from '@/lib/acl' import { tryGetDeploymentByIdentifier } from '@/lib/deployments/try-get-deployment-by-identifier' import { openapiAuthenticatedSecuritySchemas, openapiErrorResponse404, openapiErrorResponses } from '@/lib/openapi-utils' import { deploymentIdentifierAndPopulateSchema } from './schemas' const route = createRoute({ description: 'Gets a deployment by its identifier (eg, "@username/project-slug@latest").', tags: ['deployments'], operationId: 'getDeploymentByIdentifier', method: 'get', path: 'deployments/by-identifier', security: openapiAuthenticatedSecuritySchemas, request: { query: deploymentIdentifierAndPopulateSchema }, responses: { 200: { description: 'A deployment object', content: { 'application/json': { schema: schema.deploymentSelectSchema } } }, ...openapiErrorResponses, ...openapiErrorResponse404 } }) export function registerV1GetDeploymentByIdentifier( app: OpenAPIHono ) { return app.openapi(route, async (c) => { const { deploymentIdentifier, populate = [] } = c.req.valid('query') const deployment = await tryGetDeploymentByIdentifier(c, { deploymentIdentifier, with: { ...Object.fromEntries(populate.map((field) => [field, true])) } }) assert(deployment, 404, `Deployment not found "${deploymentIdentifier}"`) await acl(c, deployment, { label: 'Deployment' }) return c.json(parseZodSchema(schema.deploymentSelectSchema, deployment)) }) } ================================================ FILE: apps/api/src/api-v1/deployments/get-deployment.ts ================================================ import { assert, parseZodSchema } from '@agentic/platform-core' import { createRoute, type OpenAPIHono } from '@hono/zod-openapi' import type { AuthenticatedHonoEnv } from '@/lib/types' import { schema } from '@/db' import { acl } from '@/lib/acl' import { getDeploymentById } from '@/lib/deployments/get-deployment-by-id' import { openapiAuthenticatedSecuritySchemas, openapiErrorResponse404, openapiErrorResponses } from '@/lib/openapi-utils' import { deploymentIdParamsSchema, populateDeploymentSchema } from './schemas' const route = createRoute({ description: 'Gets a deployment by its ID', tags: ['deployments'], operationId: 'getDeployment', method: 'get', path: 'deployments/{deploymentId}', security: openapiAuthenticatedSecuritySchemas, request: { params: deploymentIdParamsSchema, query: populateDeploymentSchema }, responses: { 200: { description: 'A deployment object', content: { 'application/json': { schema: schema.deploymentSelectSchema } } }, ...openapiErrorResponses, ...openapiErrorResponse404 } }) export function registerV1GetDeployment( app: OpenAPIHono ) { return app.openapi(route, async (c) => { const { deploymentId } = c.req.valid('param') const { populate = [] } = c.req.valid('query') const deployment = await getDeploymentById({ deploymentId, with: { ...Object.fromEntries(populate.map((field) => [field, true])) } }) assert(deployment, 404, `Deployment not found "${deploymentId}"`) await acl(c, deployment, { label: 'Deployment' }) return c.json(parseZodSchema(schema.deploymentSelectSchema, deployment)) }) } ================================================ FILE: apps/api/src/api-v1/deployments/get-public-deployment-by-identifier.ts ================================================ import type { DefaultHonoEnv } from '@agentic/platform-hono' import { assert, parseZodSchema } from '@agentic/platform-core' import { createRoute, type OpenAPIHono } from '@hono/zod-openapi' import { schema } from '@/db' import { aclPublicProject } from '@/lib/acl-public-project' import { setPublicCacheControl } from '@/lib/cache-control' import { tryGetDeploymentByIdentifier } from '@/lib/deployments/try-get-deployment-by-identifier' import { openapiAuthenticatedSecuritySchemas, openapiErrorResponse404, openapiErrorResponses } from '@/lib/openapi-utils' import { deploymentIdentifierAndPopulateSchema } from './schemas' const route = createRoute({ description: 'Gets a public deployment by its identifier (eg, "@username/project-slug@latest").', tags: ['deployments'], operationId: 'getPublicDeploymentByIdentifier', method: 'get', path: 'deployments/public/by-identifier', security: openapiAuthenticatedSecuritySchemas, request: { query: deploymentIdentifierAndPopulateSchema }, responses: { 200: { description: 'A deployment object', content: { 'application/json': { schema: schema.deploymentSelectSchema } } }, ...openapiErrorResponses, ...openapiErrorResponse404 } }) export function registerV1GetPublicDeploymentByIdentifier( app: OpenAPIHono ) { return app.openapi(route, async (c) => { const { deploymentIdentifier, populate = [] } = c.req.valid('query') const deployment = await tryGetDeploymentByIdentifier(c, { deploymentIdentifier, with: { project: true, ...Object.fromEntries(populate.map((field) => [field, true])) } }) assert(deployment, 404, `Deployment not found "${deploymentIdentifier}"`) assert( deployment.project, 404, `Project not found for deployment "${deploymentIdentifier}"` ) aclPublicProject(deployment.project!) if (deployment.published) { // Note that published deployments should be immutable setPublicCacheControl(c.res, '1m') } else { setPublicCacheControl(c.res, '10s') } return c.json(parseZodSchema(schema.deploymentSelectSchema, deployment)) }) } ================================================ FILE: apps/api/src/api-v1/deployments/list-deployments.ts ================================================ import { parseZodSchema } from '@agentic/platform-core' import { createRoute, type OpenAPIHono, z } from '@hono/zod-openapi' import type { AuthenticatedHonoEnv } from '@/lib/types' import { and, db, eq, schema } from '@/db' import { acl } from '@/lib/acl' import { ensureAuthUser } from '@/lib/ensure-auth-user' import { openapiAuthenticatedSecuritySchemas, openapiErrorResponses } from '@/lib/openapi-utils' import { tryGetProjectByIdentifier } from '@/lib/projects/try-get-project-by-identifier' import { paginationAndPopulateAndFilterDeploymentSchema } from './schemas' const route = createRoute({ description: 'Lists deployments the user or team has access to, optionally filtering by project.', tags: ['deployments'], operationId: 'listDeployments', method: 'get', path: 'deployments', security: openapiAuthenticatedSecuritySchemas, request: { query: paginationAndPopulateAndFilterDeploymentSchema }, responses: { 200: { description: 'A list of deployments', content: { 'application/json': { schema: z.array(schema.deploymentSelectSchema) } } }, ...openapiErrorResponses } }) export function registerV1ListDeployments( app: OpenAPIHono ) { return app.openapi(route, async (c) => { const { offset = 0, limit = 10, sort = 'desc', sortBy = 'createdAt', populate = [], projectIdentifier, deploymentIdentifier } = c.req.valid('query') const userId = c.get('userId') const teamMember = c.get('teamMember') const user = await ensureAuthUser(c) const isAdmin = user.role === 'admin' let projectId: string | undefined if (projectIdentifier) { const project = await tryGetProjectByIdentifier(c, { projectIdentifier }) await acl(c, project, { label: 'Project' }) projectId = project.id } const deployments = await db.query.deployments.findMany({ where: and( isAdmin ? undefined : teamMember ? eq(schema.deployments.teamId, teamMember.teamId) : eq(schema.deployments.userId, userId), projectId ? eq(schema.deployments.projectId, projectId) : undefined, deploymentIdentifier ? eq(schema.deployments.identifier, deploymentIdentifier) : undefined ), with: { ...Object.fromEntries(populate.map((field) => [field, true])) }, orderBy: (deployments, { asc, desc }) => [ sort === 'desc' ? desc(deployments[sortBy]) : asc(deployments[sortBy]) ], offset, limit }) return c.json( parseZodSchema(z.array(schema.deploymentSelectSchema), deployments) ) }) } ================================================ FILE: apps/api/src/api-v1/deployments/publish-deployment.ts ================================================ import { assert, parseZodSchema } from '@agentic/platform-core' import { createRoute, type OpenAPIHono } from '@hono/zod-openapi' import type { AuthenticatedHonoEnv } from '@/lib/types' import { schema } from '@/db' import { acl } from '@/lib/acl' import { getDeploymentById } from '@/lib/deployments/get-deployment-by-id' import { publishDeployment } from '@/lib/deployments/publish-deployment' import { openapiAuthenticatedSecuritySchemas, openapiErrorResponse404, openapiErrorResponses } from '@/lib/openapi-utils' import { deploymentIdParamsSchema } from './schemas' const route = createRoute({ description: 'Publishes a deployment.', tags: ['deployments'], operationId: 'publishDeployment', method: 'post', path: 'deployments/{deploymentId}/publish', security: openapiAuthenticatedSecuritySchemas, request: { params: deploymentIdParamsSchema, body: { required: true, content: { 'application/json': { schema: schema.deploymentPublishSchema } } } }, responses: { 200: { description: 'A deployment object', content: { 'application/json': { schema: schema.deploymentSelectSchema } } }, ...openapiErrorResponses, ...openapiErrorResponse404 } }) export function registerV1PublishDeployment( app: OpenAPIHono ) { return app.openapi(route, async (c) => { const { deploymentId } = c.req.valid('param') const { version } = c.req.valid('json') // First ensure the deployment exists and the user has access to it const deployment = await getDeploymentById({ deploymentId }) assert(deployment, 404, `Deployment not found "${deploymentId}"`) await acl(c, deployment, { label: 'Deployment' }) const publishedDeployment = await publishDeployment(c, { deployment, version }) return c.json( parseZodSchema(schema.deploymentSelectSchema, publishedDeployment) ) }) } ================================================ FILE: apps/api/src/api-v1/deployments/schemas.ts ================================================ import { z } from '@hono/zod-openapi' import { deploymentIdentifierSchema, deploymentIdSchema, deploymentRelationsSchema, paginationSchema, projectIdentifierSchema } from '@/db' export const deploymentIdParamsSchema = z.object({ deploymentId: deploymentIdSchema.openapi({ param: { description: 'deployment ID', name: 'deploymentId', in: 'path' } }) }) export const createDeploymentQuerySchema = z.object({ publish: z .union([z.literal('true'), z.literal('false')]) .default('false') .transform((p) => p === 'true') }) export const filterDeploymentSchema = z.object({ projectIdentifier: projectIdentifierSchema.optional(), deploymentIdentifier: deploymentIdentifierSchema.optional() }) export const populateDeploymentSchema = z.object({ populate: z .union([deploymentRelationsSchema, z.array(deploymentRelationsSchema)]) .default([]) .transform((p) => (Array.isArray(p) ? p : [p])) .optional() }) export const deploymentIdentifierQuerySchema = z.object({ deploymentIdentifier: deploymentIdentifierSchema }) export const deploymentIdentifierAndPopulateSchema = z.object({ ...populateDeploymentSchema.shape, ...deploymentIdentifierQuerySchema.shape }) export const paginationAndPopulateAndFilterDeploymentSchema = z.object({ ...paginationSchema.shape, ...populateDeploymentSchema.shape, ...filterDeploymentSchema.shape }) ================================================ FILE: apps/api/src/api-v1/deployments/update-deployment.ts ================================================ import { assert, parseZodSchema } from '@agentic/platform-core' import { createRoute, type OpenAPIHono } from '@hono/zod-openapi' import type { AuthenticatedHonoEnv } from '@/lib/types' import { db, eq, schema } from '@/db' import { acl } from '@/lib/acl' import { getDeploymentById } from '@/lib/deployments/get-deployment-by-id' import { openapiAuthenticatedSecuritySchemas, openapiErrorResponse404, openapiErrorResponses } from '@/lib/openapi-utils' import { deploymentIdParamsSchema } from './schemas' const route = createRoute({ description: 'Updates a deployment.', tags: ['deployments'], operationId: 'updateDeployment', method: 'post', path: 'deployments/{deploymentId}', security: openapiAuthenticatedSecuritySchemas, request: { params: deploymentIdParamsSchema, body: { required: true, content: { 'application/json': { schema: schema.deploymentUpdateSchema } } } }, responses: { 200: { description: 'A deployment object', content: { 'application/json': { schema: schema.deploymentSelectSchema } } }, ...openapiErrorResponses, ...openapiErrorResponse404 } }) export function registerV1UpdateDeployment( app: OpenAPIHono ) { return app.openapi(route, async (c) => { const { deploymentId } = c.req.valid('param') const body = c.req.valid('json') // First ensure the deployment exists and the user has access to it let deployment = await getDeploymentById({ deploymentId }) assert(deployment, 404, `Deployment not found "${deploymentId}"`) await acl(c, deployment, { label: 'Deployment' }) // Update the deployment ;[deployment] = await db .update(schema.deployments) .set(body) .where(eq(schema.deployments.id, deploymentId)) .returning() assert(deployment, 500, `Failed to update deployment "${deploymentId}"`) return c.json(parseZodSchema(schema.deploymentSelectSchema, deployment)) }) } ================================================ FILE: apps/api/src/api-v1/health-check.ts ================================================ import { createRoute, z } from '@hono/zod-openapi' import type { HonoApp } from '@/lib/types' const route = createRoute({ description: 'Health check endpoint.', operationId: 'healthCheck', method: 'get', path: 'health', responses: { 200: { description: 'OK', content: { 'application/json': { schema: z.object({ status: z.string() }) } } } } }) export function registerHealthCheck(app: HonoApp) { return app.openapi(route, async (c) => { return c.json({ status: 'ok' }) }) } ================================================ FILE: apps/api/src/api-v1/index.ts ================================================ import type { DefaultHonoEnv } from '@agentic/platform-hono' import { OpenAPIHono } from '@hono/zod-openapi' import type { AuthenticatedHonoEnv } from '@/lib/types' import * as middleware from '@/lib/middleware' import { defaultHook, registerOpenAPIErrorResponses } from '@/lib/openapi-utils' import { registerV1GitHubOAuthCallback } from './auth/github-callback' import { registerV1GitHubOAuthExchange } from './auth/github-exchange' import { registerV1GitHubOAuthInitFlow } from './auth/github-init' import { registerV1SignInWithPassword } from './auth/sign-in-with-password' import { registerV1SignUpWithPassword } from './auth/sign-up-with-password' import { registerV1AdminActivateConsumer } from './consumers/admin-activate-consumer' import { registerV1AdminGetConsumerByApiKey } from './consumers/admin-get-consumer-by-api-key' import { registerV1CreateBillingPortalSession } from './consumers/create-billing-portal-session' import { registerV1CreateConsumer } from './consumers/create-consumer' import { registerV1CreateConsumerBillingPortalSession } from './consumers/create-consumer-billing-portal-session' import { registerV1CreateConsumerCheckoutSession } from './consumers/create-consumer-checkout-session' import { registerV1GetConsumer } from './consumers/get-consumer' import { registerV1GetConsumerByProjectIdentifier } from './consumers/get-consumer-by-project-identifier' import { registerV1ListConsumers } from './consumers/list-consumers' import { registerV1ListConsumersForProject } from './consumers/list-project-consumers' import { registerV1RefreshConsumerApiKey } from './consumers/refresh-consumer-api-key' import { registerV1UpdateConsumer } from './consumers/update-consumer' import { registerV1AdminGetDeploymentByIdentifier } from './deployments/admin-get-deployment-by-identifier' import { registerV1CreateDeployment } from './deployments/create-deployment' import { registerV1GetDeployment } from './deployments/get-deployment' import { registerV1GetDeploymentByIdentifier } from './deployments/get-deployment-by-identifier' import { registerV1GetPublicDeploymentByIdentifier } from './deployments/get-public-deployment-by-identifier' import { registerV1ListDeployments } from './deployments/list-deployments' import { registerV1PublishDeployment } from './deployments/publish-deployment' import { registerV1UpdateDeployment } from './deployments/update-deployment' import { registerHealthCheck } from './health-check' import { registerV1CreateProject } from './projects/create-project' import { registerV1GetProject } from './projects/get-project' import { registerV1GetProjectByIdentifier } from './projects/get-project-by-identifier' import { registerV1GetPublicProject } from './projects/get-public-project' import { registerV1GetPublicProjectByIdentifier } from './projects/get-public-project-by-identifier' import { registerV1ListProjects } from './projects/list-projects' import { registerV1ListPublicProjects } from './projects/list-public-projects' import { registerV1UpdateProject } from './projects/update-project' import { registerV1GetSignedStorageUploadUrl } from './storage/get-signed-storage-upload-url' import { registerV1CreateTeam } from './teams/create-team' import { registerV1DeleteTeam } from './teams/delete-team' import { registerV1GetTeam } from './teams/get-team' import { registerV1ListTeams } from './teams/list-teams' import { registerV1CreateTeamMember } from './teams/members/create-team-member' import { registerV1DeleteTeamMember } from './teams/members/delete-team-member' import { registerV1UpdateTeamMember } from './teams/members/update-team-member' import { registerV1UpdateTeam } from './teams/update-team' import { registerV1GetUser } from './users/get-user' import { registerV1UpdateUser } from './users/update-user' import { registerV1StripeWebhook } from './webhooks/stripe-webhook' // Note that the order of some of these routes is important because of // wildcards, so be careful when updating them or adding new routes. export const apiV1 = new OpenAPIHono({ defaultHook }) apiV1.openAPIRegistry.registerComponent('securitySchemes', 'Bearer', { type: 'http', scheme: 'bearer', bearerFormat: 'JWT' }) registerOpenAPIErrorResponses(apiV1) // Public routes const publicRouter = new OpenAPIHono({ defaultHook }) // Private, authenticated routes const privateRouter = new OpenAPIHono({ defaultHook }) registerHealthCheck(publicRouter) // Auth registerV1SignInWithPassword(publicRouter) registerV1SignUpWithPassword(publicRouter) registerV1GitHubOAuthExchange(publicRouter) registerV1GitHubOAuthInitFlow(publicRouter) registerV1GitHubOAuthCallback(publicRouter) // Users registerV1GetUser(privateRouter) registerV1UpdateUser(privateRouter) // Teams registerV1CreateTeam(privateRouter) registerV1ListTeams(privateRouter) registerV1GetTeam(privateRouter) registerV1DeleteTeam(privateRouter) registerV1UpdateTeam(privateRouter) // Team members registerV1CreateTeamMember(privateRouter) registerV1UpdateTeamMember(privateRouter) registerV1DeleteTeamMember(privateRouter) // Storage registerV1GetSignedStorageUploadUrl(privateRouter) // Public projects registerV1ListPublicProjects(publicRouter) registerV1GetPublicProjectByIdentifier(publicRouter) // must be before `registerV1GetPublicProject` registerV1GetPublicProject(publicRouter) // Private projects registerV1CreateProject(privateRouter) registerV1ListProjects(privateRouter) registerV1GetProjectByIdentifier(privateRouter) // must be before `registerV1GetProject` registerV1GetProject(privateRouter) registerV1UpdateProject(privateRouter) // Consumers registerV1GetConsumerByProjectIdentifier(privateRouter) // must be before `registerV1GetConsumer` registerV1CreateBillingPortalSession(privateRouter) registerV1GetConsumer(privateRouter) registerV1CreateConsumer(privateRouter) registerV1CreateConsumerCheckoutSession(privateRouter) registerV1CreateConsumerBillingPortalSession(privateRouter) registerV1UpdateConsumer(privateRouter) registerV1RefreshConsumerApiKey(privateRouter) registerV1ListConsumers(privateRouter) registerV1ListConsumersForProject(privateRouter) // Deployments registerV1GetPublicDeploymentByIdentifier(publicRouter) registerV1GetDeploymentByIdentifier(privateRouter) // must be before `registerV1GetDeployment` registerV1GetDeployment(privateRouter) registerV1CreateDeployment(privateRouter) registerV1UpdateDeployment(privateRouter) registerV1ListDeployments(privateRouter) registerV1PublishDeployment(privateRouter) // Internal admin routes registerV1AdminGetConsumerByApiKey(privateRouter) registerV1AdminActivateConsumer(privateRouter) registerV1AdminGetDeploymentByIdentifier(privateRouter) // Webhook event handlers registerV1StripeWebhook(publicRouter) // Setup routes and middleware apiV1.route('/', publicRouter) apiV1.use(middleware.authenticate) apiV1.use(middleware.team) apiV1.use(middleware.me) apiV1.route('/', privateRouter) // API route types to be used by Hono's RPC client. // Should include all routes except for internal and admin routes. // NOTE: Removing for now because Hono's RPC client / types are clunky and slow. // export type ApiRoutes = // | ReturnType ================================================ FILE: apps/api/src/api-v1/projects/create-project.ts ================================================ import { assert, parseZodSchema, sha256 } from '@agentic/platform-core' import { parseProjectIdentifier } from '@agentic/platform-validators' import { createRoute, type OpenAPIHono } from '@hono/zod-openapi' import type { AuthenticatedHonoEnv } from '@/lib/types' import { db, schema } from '@/db' import { ensureAuthUser } from '@/lib/ensure-auth-user' import { env } from '@/lib/env' import { openapiAuthenticatedSecuritySchemas, openapiErrorResponses } from '@/lib/openapi-utils' const route = createRoute({ description: 'Creates a new project.', tags: ['projects'], operationId: 'createProject', method: 'post', path: 'projects', security: openapiAuthenticatedSecuritySchemas, request: { body: { required: true, content: { 'application/json': { schema: schema.projectInsertSchema } } } }, responses: { 200: { description: 'The created project', content: { 'application/json': { schema: schema.projectSelectSchema } } }, ...openapiErrorResponses } }) export function registerV1CreateProject( app: OpenAPIHono ) { return app.openapi(route, async (c) => { const body = c.req.valid('json') const user = await ensureAuthUser(c) // if (body.teamId) { // await aclTeamMember(c, { teamId: body.teamId }) // } const teamMember = c.get('teamMember') const namespace = teamMember ? teamMember.teamSlug : user.username const identifier = `@${namespace}/${body.slug}` const { projectIdentifier, projectNamespace, projectSlug } = parseProjectIdentifier(identifier) // Used for testing e2e fixtures in the development marketplace const isPrivate = !( (user.username === 'dev' && env.isDev) || user.username === 'agentic' ) // Used to simplify recreating the demo `@agentic/search` project during // development while we're frequently resetting the database const secret = projectIdentifier === '@dev/search' || projectIdentifier === '@agentic/search' ? env.AGENTIC_SEARCH_PROXY_SECRET : await sha256() const [project] = await db .insert(schema.projects) .values({ ...body, identifier: projectIdentifier, namespace: projectNamespace, slug: projectSlug, teamId: teamMember?.teamId, userId: user.id, private: isPrivate, _secret: secret }) .returning() assert(project, 500, `Failed to create project "${body.name}"`) return c.json(parseZodSchema(schema.projectSelectSchema, project)) }) } ================================================ FILE: apps/api/src/api-v1/projects/get-project-by-identifier.ts ================================================ import { assert, parseZodSchema } from '@agentic/platform-core' import { createRoute, type OpenAPIHono } from '@hono/zod-openapi' import type { AuthenticatedHonoEnv } from '@/lib/types' import { db, eq, schema } from '@/db' import { acl } from '@/lib/acl' import { openapiAuthenticatedSecuritySchemas, openapiErrorResponse404, openapiErrorResponses } from '@/lib/openapi-utils' import { projectIdentifierAndPopulateSchema } from './schemas' const route = createRoute({ description: 'Gets a project by its public identifier (eg, "@username/project-slug").', tags: ['projects'], operationId: 'getProjectByIdentifier', method: 'get', path: 'projects/by-identifier', security: openapiAuthenticatedSecuritySchemas, request: { query: projectIdentifierAndPopulateSchema }, responses: { 200: { description: 'A project', content: { 'application/json': { schema: schema.projectSelectSchema } } }, ...openapiErrorResponses, ...openapiErrorResponse404 } }) export function registerV1GetProjectByIdentifier( app: OpenAPIHono ) { return app.openapi(route, async (c) => { const { projectIdentifier, populate = [] } = c.req.valid('query') const project = await db.query.projects.findFirst({ where: eq(schema.projects.identifier, projectIdentifier), with: { lastPublishedDeployment: true, ...Object.fromEntries(populate.map((field) => [field, true])) } }) assert(project, 404, `Project not found "${projectIdentifier}"`) await acl(c, project, { label: 'Project' }) return c.json(parseZodSchema(schema.projectSelectSchema, project)) }) } ================================================ FILE: apps/api/src/api-v1/projects/get-project.ts ================================================ import { assert, parseZodSchema } from '@agentic/platform-core' import { createRoute, type OpenAPIHono } from '@hono/zod-openapi' import type { AuthenticatedHonoEnv } from '@/lib/types' import { db, eq, schema } from '@/db' import { acl } from '@/lib/acl' import { openapiAuthenticatedSecuritySchemas, openapiErrorResponse404, openapiErrorResponses } from '@/lib/openapi-utils' import { populateProjectSchema, projectIdParamsSchema } from './schemas' const route = createRoute({ description: 'Gets a project by ID.', tags: ['projects'], operationId: 'getProject', method: 'get', path: 'projects/{projectId}', security: openapiAuthenticatedSecuritySchemas, request: { params: projectIdParamsSchema, query: populateProjectSchema }, responses: { 200: { description: 'A project', content: { 'application/json': { schema: schema.projectSelectSchema } } }, ...openapiErrorResponses, ...openapiErrorResponse404 } }) export function registerV1GetProject(app: OpenAPIHono) { return app.openapi(route, async (c) => { const { projectId } = c.req.valid('param') const { populate = [] } = c.req.valid('query') const project = await db.query.projects.findFirst({ where: eq(schema.projects.id, projectId), with: { lastPublishedDeployment: true, ...Object.fromEntries(populate.map((field) => [field, true])) } }) assert(project, 404, `Project not found "${projectId}"`) await acl(c, project, { label: 'Project' }) return c.json(parseZodSchema(schema.projectSelectSchema, project)) }) } ================================================ FILE: apps/api/src/api-v1/projects/get-public-project-by-identifier.ts ================================================ import type { DefaultHonoEnv } from '@agentic/platform-hono' import { parseZodSchema } from '@agentic/platform-core' import { createRoute, type OpenAPIHono } from '@hono/zod-openapi' import { db, eq, schema } from '@/db' import { aclPublicProject } from '@/lib/acl-public-project' import { setPublicCacheControl } from '@/lib/cache-control' import { env } from '@/lib/env' import { openapiAuthenticatedSecuritySchemas, openapiErrorResponse404, openapiErrorResponses } from '@/lib/openapi-utils' import { projectIdentifierAndPopulateSchema } from './schemas' const route = createRoute({ description: 'Gets a public project by its public identifier (eg, "@username/project-slug").', tags: ['projects'], operationId: 'getPublicProjectByIdentifier', method: 'get', path: 'projects/public/by-identifier', security: openapiAuthenticatedSecuritySchemas, request: { query: projectIdentifierAndPopulateSchema }, responses: { 200: { description: 'A project', content: { 'application/json': { schema: schema.projectSelectSchema } } }, ...openapiErrorResponses, ...openapiErrorResponse404 } }) export function registerV1GetPublicProjectByIdentifier( app: OpenAPIHono ) { return app.openapi(route, async (c) => { const { projectIdentifier, populate = [] } = c.req.valid('query') const project = await db.query.projects.findFirst({ where: eq(schema.projects.identifier, projectIdentifier), with: { lastPublishedDeployment: true, ...Object.fromEntries(populate.map((field) => [field, true])) } }) aclPublicProject(project, projectIdentifier) setPublicCacheControl(c.res, env.isProd ? '1m' : '10s') return c.json(parseZodSchema(schema.projectSelectSchema, project)) }) } ================================================ FILE: apps/api/src/api-v1/projects/get-public-project.ts ================================================ import type { DefaultHonoEnv } from '@agentic/platform-hono' import { parseZodSchema } from '@agentic/platform-core' import { createRoute, type OpenAPIHono } from '@hono/zod-openapi' import { db, eq, schema } from '@/db' import { aclPublicProject } from '@/lib/acl-public-project' import { setPublicCacheControl } from '@/lib/cache-control' import { env } from '@/lib/env' import { openapiAuthenticatedSecuritySchemas, openapiErrorResponse404, openapiErrorResponses } from '@/lib/openapi-utils' import { populateProjectSchema, projectIdParamsSchema } from './schemas' const route = createRoute({ description: 'Gets a public project by ID.', tags: ['projects'], operationId: 'getPublicProject', method: 'get', path: 'projects/public/{projectId}', security: openapiAuthenticatedSecuritySchemas, request: { params: projectIdParamsSchema, query: populateProjectSchema }, responses: { 200: { description: 'A project', content: { 'application/json': { schema: schema.projectSelectSchema } } }, ...openapiErrorResponses, ...openapiErrorResponse404 } }) export function registerV1GetPublicProject(app: OpenAPIHono) { return app.openapi(route, async (c) => { const { projectId } = c.req.valid('param') const { populate = [] } = c.req.valid('query') const project = await db.query.projects.findFirst({ where: eq(schema.projects.id, projectId), with: { lastPublishedDeployment: true, ...Object.fromEntries(populate.map((field) => [field, true])) } }) aclPublicProject(project, projectId) setPublicCacheControl(c.res, env.isProd ? '1m' : '10s') return c.json(parseZodSchema(schema.projectSelectSchema, project)) }) } ================================================ FILE: apps/api/src/api-v1/projects/list-projects.ts ================================================ import { parseZodSchema } from '@agentic/platform-core' import { createRoute, type OpenAPIHono, z } from '@hono/zod-openapi' import type { AuthenticatedHonoEnv } from '@/lib/types' import { db, eq, schema } from '@/db' import { ensureAuthUser } from '@/lib/ensure-auth-user' import { openapiAuthenticatedSecuritySchemas, openapiErrorResponses } from '@/lib/openapi-utils' import { paginationAndPopulateProjectSchema } from './schemas' const route = createRoute({ description: 'Lists projects owned by the authenticated user or team.', tags: ['projects'], operationId: 'listProjects', method: 'get', path: 'projects', security: openapiAuthenticatedSecuritySchemas, request: { query: paginationAndPopulateProjectSchema }, responses: { 200: { description: 'A list of projects', content: { 'application/json': { schema: z.array(schema.projectSelectSchema) } } }, ...openapiErrorResponses } }) export function registerV1ListProjects(app: OpenAPIHono) { return app.openapi(route, async (c) => { const { offset = 0, limit = 10, sort = 'desc', sortBy = 'createdAt', populate = [] } = c.req.valid('query') const user = await ensureAuthUser(c) const teamMember = c.get('teamMember') const isAdmin = user.role === 'admin' const projects = await db.query.projects.findMany({ where: isAdmin ? undefined : teamMember ? eq(schema.projects.teamId, teamMember.teamId) : eq(schema.projects.userId, user.id), with: { lastPublishedDeployment: true, ...Object.fromEntries(populate.map((field) => [field, true])) }, orderBy: (projects, { asc, desc }) => [ sort === 'desc' ? desc(projects[sortBy]) : asc(projects[sortBy]) ], offset, limit }) return c.json(parseZodSchema(z.array(schema.projectSelectSchema), projects)) }) } ================================================ FILE: apps/api/src/api-v1/projects/list-public-projects.ts ================================================ import { env } from 'node:process' import type { DefaultHonoEnv } from '@agentic/platform-hono' import { parseZodSchema } from '@agentic/platform-core' import { createRoute, type OpenAPIHono, z } from '@hono/zod-openapi' import { and, arrayContains, db, eq, isNotNull, isNull, not, or, schema } from '@/db' import { setPublicCacheControl } from '@/lib/cache-control' import { openapiAuthenticatedSecuritySchemas, openapiErrorResponses } from '@/lib/openapi-utils' import { listPublicProjectsQuerySchema } from './schemas' const route = createRoute({ description: 'Lists projects that have been published publicly to the marketplace.', tags: ['projects'], operationId: 'listPublicProjects', method: 'get', path: 'projects/public', security: openapiAuthenticatedSecuritySchemas, request: { query: listPublicProjectsQuerySchema }, responses: { 200: { description: 'A list of projects', content: { 'application/json': { schema: z.array(schema.projectSelectSchema) } } }, ...openapiErrorResponses } }) export function registerV1ListPublicProjects(app: OpenAPIHono) { return app.openapi(route, async (c) => { const { offset = 0, limit = 10, sort = 'desc', sortBy = 'createdAt', populate = [], tag, notTag } = c.req.valid('query') const projects = await db.query.projects.findMany({ // List projects that are not private and have at least one published deployment // And optionally match a given tag where: and( eq(schema.projects.private, false), isNotNull(schema.projects.lastPublishedDeploymentId), tag ? arrayContains(schema.projects.tags, [tag]) : undefined, notTag ? or( not(arrayContains(schema.projects.tags, [notTag])), isNull(schema.projects.tags) ) : undefined ), with: { lastPublishedDeployment: true, ...Object.fromEntries(populate.map((field) => [field, true])) }, orderBy: (projects, { asc, desc }) => [ sort === 'desc' ? desc(projects[sortBy]) : asc(projects[sortBy]) ], offset, limit }) setPublicCacheControl(c.res, env.isProd ? '1m' : '10s') return c.json(parseZodSchema(z.array(schema.projectSelectSchema), projects)) }) } ================================================ FILE: apps/api/src/api-v1/projects/schemas.ts ================================================ // import { isValidNamespace } from '@agentic/platform-validators' import { z } from '@hono/zod-openapi' import { paginationSchema, projectIdentifierSchema, projectIdSchema, projectRelationsSchema } from '@/db' export const projectIdParamsSchema = z.object({ projectId: projectIdSchema.openapi({ param: { description: 'Project ID', name: 'projectId', in: 'path' } }) }) // export const namespaceParamsSchema = z.object({ // namespace: z // .string() // .refine((namespace) => isValidNamespace(namespace), { // message: 'Invalid namespace' // }) // .openapi({ // param: { // description: 'Namespace', // name: 'namespace', // in: 'path' // } // }) // }) export const projectIdentifierQuerySchema = z.object({ projectIdentifier: projectIdentifierSchema }) export const filterPublicProjectSchema = z.object({ tag: z.string().optional(), notTag: z.string().optional() }) export const populateProjectSchema = z.object({ populate: z .union([projectRelationsSchema, z.array(projectRelationsSchema)]) .default([]) .transform((p) => (Array.isArray(p) ? p : [p])) .optional() }) export const projectIdentifierAndPopulateSchema = z.object({ ...populateProjectSchema.shape, ...projectIdentifierQuerySchema.shape }) export const paginationAndPopulateProjectSchema = z.object({ ...paginationSchema.shape, ...populateProjectSchema.shape }) export const listPublicProjectsQuerySchema = z.object({ ...paginationSchema.shape, ...populateProjectSchema.shape, ...filterPublicProjectSchema.shape }) ================================================ FILE: apps/api/src/api-v1/projects/update-project.ts ================================================ import { assert, parseZodSchema } from '@agentic/platform-core' import { createRoute, type OpenAPIHono } from '@hono/zod-openapi' import type { AuthenticatedHonoEnv } from '@/lib/types' import { db, eq, schema } from '@/db' import { acl } from '@/lib/acl' import { openapiAuthenticatedSecuritySchemas, openapiErrorResponse404, openapiErrorResponses } from '@/lib/openapi-utils' import { projectIdParamsSchema } from './schemas' const route = createRoute({ description: 'Updates a project.', tags: ['projects'], operationId: 'updateProject', method: 'post', path: 'projects/{projectId}', security: openapiAuthenticatedSecuritySchemas, request: { params: projectIdParamsSchema, body: { required: true, content: { 'application/json': { schema: schema.projectUpdateSchema } } } }, responses: { 200: { description: 'The updated project', content: { 'application/json': { schema: schema.projectSelectSchema } } }, ...openapiErrorResponses, ...openapiErrorResponse404 } }) export function registerV1UpdateProject( app: OpenAPIHono ) { return app.openapi(route, async (c) => { const { projectId } = c.req.valid('param') const body = c.req.valid('json') // First ensure the project exists and the user has access to it let project = await db.query.projects.findFirst({ where: eq(schema.projects.id, projectId) }) assert(project, 404, `Project not found "${projectId}"`) await acl(c, project, { label: 'Project' }) // Update the project ;[project] = await db .update(schema.projects) .set(body) .where(eq(schema.projects.id, projectId)) .returning() assert(project, 500, `Failed to update project "${projectId}"`) return c.json(parseZodSchema(schema.projectSelectSchema, project)) }) } ================================================ FILE: apps/api/src/api-v1/storage/get-signed-storage-upload-url.ts ================================================ import { assert } from '@agentic/platform-core' import { createRoute, type OpenAPIHono, z } from '@hono/zod-openapi' import type { AuthenticatedHonoEnv } from '@/lib/types' import { db, eq, projectIdentifierSchema, schema } from '@/db' import { acl } from '@/lib/acl' import { openapiAuthenticatedSecuritySchemas, openapiErrorResponse404, openapiErrorResponses } from '@/lib/openapi-utils' import { getStorageObjectPublicUrl, getStorageSignedUploadUrl } from '@/lib/storage' export const getSignedUploadUrlQuerySchema = z.object({ projectIdentifier: projectIdentifierSchema, /** * Should be a hash of the contents of the file to upload with the correct * file extension. * * @example `9f86d081884c7d659a2feaa0c55ad015a.png` */ key: z .string() .nonempty() .describe( 'Should be a hash of the contents of the file to upload with the correct file extension (eg, "9f86d081884c7d659a2feaa0c55ad015a.png").' ) }) const route = createRoute({ description: "Gets a signed URL for uploading a file to Agentic's blob storage. Files are namespaced to a given project and are identified by a key that should be a hash of the file's contents, with the correct file extension.", tags: ['storage'], operationId: 'getSignedStorageUploadUrl', method: 'get', path: 'storage/signed-upload-url', security: openapiAuthenticatedSecuritySchemas, request: { query: getSignedUploadUrlQuerySchema }, responses: { 200: { description: 'A signed upload URL', content: { 'application/json': { schema: z.object({ signedUploadUrl: z .string() .url() .describe('The signed upload URL.'), publicObjectUrl: z .string() .url() .describe('The public URL the object will have once uploaded.') }) } } }, ...openapiErrorResponses, ...openapiErrorResponse404 } }) export function registerV1GetSignedStorageUploadUrl( app: OpenAPIHono ) { return app.openapi(route, async (c) => { const { projectIdentifier, key } = c.req.valid('query') const project = await db.query.projects.findFirst({ where: eq(schema.projects.identifier, projectIdentifier) }) assert(project, 404, `Project not found "${projectIdentifier}"`) await acl(c, project, { label: 'Project' }) const compoundKey = `${project.identifier}/${key}` const signedUploadUrl = await getStorageSignedUploadUrl(compoundKey) const publicObjectUrl = getStorageObjectPublicUrl(compoundKey) return c.json({ signedUploadUrl, publicObjectUrl }) }) } ================================================ FILE: apps/api/src/api-v1/teams/create-team.ts ================================================ import { assert, parseZodSchema } from '@agentic/platform-core' import { createRoute, type OpenAPIHono } from '@hono/zod-openapi' import type { AuthenticatedHonoEnv } from '@/lib/types' import { db, schema } from '@/db' import { ensureAuthUser } from '@/lib/ensure-auth-user' import { ensureUniqueNamespace } from '@/lib/ensure-unique-namespace' import { openapiAuthenticatedSecuritySchemas, openapiErrorResponses } from '@/lib/openapi-utils' const route = createRoute({ description: 'Creates a new team.', tags: ['teams'], operationId: 'createTeam', method: 'post', path: 'teams', security: openapiAuthenticatedSecuritySchemas, request: { body: { required: true, content: { 'application/json': { schema: schema.teamInsertSchema } } } }, responses: { 200: { description: 'The created team', content: { 'application/json': { schema: schema.teamSelectSchema } } }, ...openapiErrorResponses } }) export function registerV1CreateTeam(app: OpenAPIHono) { return app.openapi(route, async (c) => { const user = await ensureAuthUser(c) const body = c.req.valid('json') await ensureUniqueNamespace(body.slug, { label: 'Team slug' }) return db.transaction(async (tx) => { const [team] = await tx .insert(schema.teams) .values({ ...body, ownerId: user.id }) .returning() assert(team, 500, `Failed to create team "${body.slug}"`) const [teamMember] = await tx .insert(schema.teamMembers) .values({ userId: user.id, teamId: team.id, teamSlug: team.slug, role: 'admin', confirmed: true }) .returning() assert( teamMember, 500, `Failed to create team member owner for team "${body.slug}"` ) return c.json(parseZodSchema(schema.teamSelectSchema, team)) }) }) } ================================================ FILE: apps/api/src/api-v1/teams/delete-team.ts ================================================ import { assert, parseZodSchema } from '@agentic/platform-core' import { createRoute, type OpenAPIHono } from '@hono/zod-openapi' import type { AuthenticatedHonoEnv } from '@/lib/types' import { db, eq, schema } from '@/db' import { aclTeamAdmin } from '@/lib/acl-team-admin' import { openapiAuthenticatedSecuritySchemas, openapiErrorResponse404, openapiErrorResponses } from '@/lib/openapi-utils' import { teamIdParamsSchema } from './schemas' const route = createRoute({ description: 'Deletes a team by ID.', tags: ['teams'], operationId: 'deleteTeam', method: 'delete', path: 'teams/{teamId}', security: openapiAuthenticatedSecuritySchemas, request: { params: teamIdParamsSchema }, responses: { 200: { description: 'The team that was deleted', content: { 'application/json': { schema: schema.teamSelectSchema } } }, ...openapiErrorResponses, ...openapiErrorResponse404 } }) export function registerV1DeleteTeam(app: OpenAPIHono) { return app.openapi(route, async (c) => { const { teamId } = c.req.valid('param') await aclTeamAdmin(c, { teamId }) const [team] = await db .delete(schema.teams) .where(eq(schema.teams.id, teamId)) .returning() assert(team, 404, `Team not found "${teamId}"`) return c.json(parseZodSchema(schema.teamSelectSchema, team)) }) } ================================================ FILE: apps/api/src/api-v1/teams/get-team.ts ================================================ import { assert, parseZodSchema } from '@agentic/platform-core' import { createRoute, type OpenAPIHono } from '@hono/zod-openapi' import type { AuthenticatedHonoEnv } from '@/lib/types' import { db, eq, schema } from '@/db' import { aclTeamMember } from '@/lib/acl-team-member' import { openapiAuthenticatedSecuritySchemas, openapiErrorResponse404, openapiErrorResponses } from '@/lib/openapi-utils' import { teamIdParamsSchema } from './schemas' const route = createRoute({ description: 'Gets a team by ID.', tags: ['teams'], operationId: 'getTeam', method: 'get', path: 'teams/{teamId}', security: openapiAuthenticatedSecuritySchemas, request: { params: teamIdParamsSchema }, responses: { 200: { description: 'A team object', content: { 'application/json': { schema: schema.teamSelectSchema } } }, ...openapiErrorResponses, ...openapiErrorResponse404 } }) export function registerV1GetTeam(app: OpenAPIHono) { return app.openapi(route, async (c) => { const { teamId } = c.req.valid('param') await aclTeamMember(c, { teamId }) const team = await db.query.teams.findFirst({ where: eq(schema.teams.id, teamId) }) assert(team, 404, `Team not found "${teamId}"`) return c.json(parseZodSchema(schema.teamSelectSchema, team)) }) } ================================================ FILE: apps/api/src/api-v1/teams/list-teams.ts ================================================ import { parseZodSchema } from '@agentic/platform-core' import { createRoute, type OpenAPIHono, z } from '@hono/zod-openapi' import type { AuthenticatedHonoEnv } from '@/lib/types' import { db, eq, paginationSchema, schema } from '@/db' import { openapiAuthenticatedSecuritySchemas, openapiErrorResponses } from '@/lib/openapi-utils' const route = createRoute({ description: 'Lists all teams the authenticated user belongs to.', tags: ['teams'], operationId: 'listTeams', method: 'get', path: 'teams', security: openapiAuthenticatedSecuritySchemas, request: { query: paginationSchema }, responses: { 200: { description: 'A list of teams', content: { 'application/json': { schema: z.array(schema.teamSelectSchema) } } }, ...openapiErrorResponses } }) export function registerV1ListTeams(app: OpenAPIHono) { return app.openapi(route, async (c) => { const { offset = 0, limit = 10, sort = 'desc', sortBy = 'createdAt' } = c.req.valid('query') const userId = c.get('userId') // schema.teamMembers._.columns const teamMembers = await db.query.teamMembers.findMany({ where: eq(schema.teamMembers.userId, userId), with: { team: true }, orderBy: (teamMembers, { asc, desc }) => [ sort === 'desc' ? desc(teamMembers[sortBy]) : asc(teamMembers[sortBy]) ], offset, limit }) return c.json(parseZodSchema(z.array(schema.teamSelectSchema), teamMembers)) }) } ================================================ FILE: apps/api/src/api-v1/teams/members/create-team-member.ts ================================================ import { assert, parseZodSchema } from '@agentic/platform-core' import { createRoute, type OpenAPIHono } from '@hono/zod-openapi' import type { AuthenticatedHonoEnv } from '@/lib/types' import { and, db, eq, schema } from '@/db' import { aclTeamAdmin } from '@/lib/acl-team-admin' import { openapiAuthenticatedSecuritySchemas, openapiErrorResponse404, openapiErrorResponse409, openapiErrorResponses } from '@/lib/openapi-utils' import { teamIdParamsSchema } from '../schemas' const route = createRoute({ description: 'Creates a new team member.', tags: ['teams'], operationId: 'createTeamMember', method: 'post', path: 'teams/{teamId}/members', security: openapiAuthenticatedSecuritySchemas, request: { params: teamIdParamsSchema, body: { required: true, content: { 'application/json': { schema: schema.teamMemberInsertSchema } } } }, responses: { 200: { description: 'The created team member', content: { 'application/json': { schema: schema.teamMemberSelectSchema } } }, ...openapiErrorResponses, ...openapiErrorResponse404, ...openapiErrorResponse409 } }) export function registerV1CreateTeamMember( app: OpenAPIHono ) { return app.openapi(route, async (c) => { const { teamId } = c.req.valid('param') const body = c.req.valid('json') await aclTeamAdmin(c, { teamId }) const team = await db.query.teams.findFirst({ where: eq(schema.teams.id, teamId) }) assert(team, 404, `Team not found "${teamId}"`) const existingTeamMember = await db.query.teamMembers.findFirst({ where: and( eq(schema.teamMembers.teamId, teamId), eq(schema.teamMembers.userId, body.userId) ) }) assert( existingTeamMember, 409, `User "${body.userId}" is already a member of team "${teamId}"` ) const [teamMember] = await db .insert(schema.teamMembers) .values({ ...body, teamId, teamSlug: team.slug }) .returning() assert( teamMember, 500, `Failed to create team member "${body.userId}"for team "${teamId}"` ) // TODO: send team invite email return c.json(parseZodSchema(schema.teamMemberSelectSchema, teamMember)) }) } ================================================ FILE: apps/api/src/api-v1/teams/members/delete-team-member.ts ================================================ import { assert, parseZodSchema } from '@agentic/platform-core' import { createRoute, type OpenAPIHono } from '@hono/zod-openapi' import type { AuthenticatedHonoEnv } from '@/lib/types' import { and, db, eq, schema } from '@/db' import { aclTeamAdmin } from '@/lib/acl-team-admin' import { aclTeamMember } from '@/lib/acl-team-member' import { openapiAuthenticatedSecuritySchemas, openapiErrorResponse404, openapiErrorResponses } from '@/lib/openapi-utils' import { teamIdTeamMemberUserIdParamsSchema } from './schemas' const route = createRoute({ description: 'Deletes a team member.', tags: ['teams'], operationId: 'deleteTeamMember', method: 'delete', path: 'teams/{teamId}/members/{userId}', security: openapiAuthenticatedSecuritySchemas, request: { params: teamIdTeamMemberUserIdParamsSchema }, responses: { 200: { description: 'The deleted team member', content: { 'application/json': { schema: schema.teamMemberSelectSchema } } }, ...openapiErrorResponses, ...openapiErrorResponse404 } }) export function registerV1DeleteTeamMember( app: OpenAPIHono ) { return app.openapi(route, async (c) => { const { teamId, userId } = c.req.valid('param') await aclTeamAdmin(c, { teamId }) await aclTeamMember(c, { teamId, userId }) const [teamMember] = await db .delete(schema.teamMembers) .where( and( eq(schema.teamMembers.teamId, teamId), eq(schema.teamMembers.userId, userId) ) ) .returning() assert( teamMember, 400, `Failed to update team member "${userId}" for team "${teamId}"` ) return c.json(parseZodSchema(schema.teamMemberSelectSchema, teamMember)) }) } ================================================ FILE: apps/api/src/api-v1/teams/members/schemas.ts ================================================ import { z } from '@hono/zod-openapi' import { userIdSchema } from '@/db' import { teamIdParamsSchema } from '../schemas' export const teamIdTeamMemberUserIdParamsSchema = z.object({ ...teamIdParamsSchema.shape, userId: userIdSchema.openapi({ param: { description: 'Team member user ID', name: 'userId', in: 'path' } }) }) ================================================ FILE: apps/api/src/api-v1/teams/members/update-team-member.ts ================================================ import { assert, parseZodSchema } from '@agentic/platform-core' import { createRoute, type OpenAPIHono } from '@hono/zod-openapi' import type { AuthenticatedHonoEnv } from '@/lib/types' import { and, db, eq, schema } from '@/db' import { aclTeamAdmin } from '@/lib/acl-team-admin' import { aclTeamMember } from '@/lib/acl-team-member' import { openapiAuthenticatedSecuritySchemas, openapiErrorResponse404, openapiErrorResponses } from '@/lib/openapi-utils' import { teamIdTeamMemberUserIdParamsSchema } from './schemas' const route = createRoute({ description: 'Updates a team member.', tags: ['teams'], operationId: 'updateTeamMember', method: 'post', path: 'teams/{teamId}/members/{userId}', security: openapiAuthenticatedSecuritySchemas, request: { params: teamIdTeamMemberUserIdParamsSchema, body: { required: true, content: { 'application/json': { schema: schema.teamMemberUpdateSchema } } } }, responses: { 200: { description: 'The updated team member', content: { 'application/json': { schema: schema.teamMemberSelectSchema } } }, ...openapiErrorResponses, ...openapiErrorResponse404 } }) export function registerV1UpdateTeamMember( app: OpenAPIHono ) { return app.openapi(route, async (c) => { const { teamId, userId } = c.req.valid('param') const body = c.req.valid('json') await aclTeamAdmin(c, { teamId }) await aclTeamMember(c, { teamId, userId }) const [teamMember] = await db .update(schema.teamMembers) .set(body) .where( and( eq(schema.teamMembers.teamId, teamId), eq(schema.teamMembers.userId, userId) ) ) .returning() assert( teamMember, 400, `Failed to update team member "${userId}" for team "${teamId}"` ) return c.json(parseZodSchema(schema.teamMemberSelectSchema, teamMember)) }) } ================================================ FILE: apps/api/src/api-v1/teams/schemas.ts ================================================ import { z } from '@hono/zod-openapi' import { teamIdSchema } from '@/db' export const teamIdParamsSchema = z.object({ teamId: teamIdSchema.openapi({ param: { description: 'Team ID', name: 'teamId', in: 'path' } }) }) ================================================ FILE: apps/api/src/api-v1/teams/update-team.ts ================================================ import { assert, parseZodSchema } from '@agentic/platform-core' import { createRoute, type OpenAPIHono } from '@hono/zod-openapi' import type { AuthenticatedHonoEnv } from '@/lib/types' import { db, eq, schema } from '@/db' import { aclTeamAdmin } from '@/lib/acl-team-admin' import { openapiAuthenticatedSecuritySchemas, openapiErrorResponse404, openapiErrorResponses } from '@/lib/openapi-utils' import { teamIdParamsSchema } from './schemas' const route = createRoute({ description: 'Updates a team.', tags: ['teams'], operationId: 'updateTeam', method: 'post', path: 'teams/{teamId}', security: openapiAuthenticatedSecuritySchemas, request: { params: teamIdParamsSchema, body: { required: true, content: { 'application/json': { schema: schema.teamUpdateSchema } } } }, responses: { 200: { description: 'The updated team', content: { 'application/json': { schema: schema.teamSelectSchema } } }, ...openapiErrorResponses, ...openapiErrorResponse404 } }) export function registerV1UpdateTeam(app: OpenAPIHono) { return app.openapi(route, async (c) => { const { teamId } = c.req.valid('param') const body = c.req.valid('json') await aclTeamAdmin(c, { teamId }) const [team] = await db .update(schema.teams) .set(body) .where(eq(schema.teams.id, teamId)) .returning() assert(team, 404, `Team not found "${teamId}"`) return c.json(parseZodSchema(schema.teamSelectSchema, team)) }) } ================================================ FILE: apps/api/src/api-v1/users/get-user.ts ================================================ import { assert, parseZodSchema } from '@agentic/platform-core' import { createRoute, type OpenAPIHono } from '@hono/zod-openapi' import type { AuthenticatedHonoEnv } from '@/lib/types' import { db, eq, schema } from '@/db' import { acl } from '@/lib/acl' import { setPublicCacheControl } from '@/lib/cache-control' import { env } from '@/lib/env' import { openapiAuthenticatedSecuritySchemas, openapiErrorResponse404, openapiErrorResponses } from '@/lib/openapi-utils' import { userIdParamsSchema } from './schemas' const route = createRoute({ description: 'Gets a user by ID.', tags: ['users'], operationId: 'getUser', method: 'get', path: 'users/{userId}', security: openapiAuthenticatedSecuritySchemas, request: { params: userIdParamsSchema }, responses: { 200: { description: 'A user object', content: { 'application/json': { schema: schema.userSelectSchema } } }, ...openapiErrorResponses, ...openapiErrorResponse404 } }) export function registerV1GetUser(app: OpenAPIHono) { return app.openapi(route, async (c) => { const { userId } = c.req.valid('param') await acl(c, { userId }, { label: 'User' }) const user = await db.query.users.findFirst({ where: eq(schema.users.id, userId) }) assert(user, 404, `User not found "${userId}"`) setPublicCacheControl(c.res, env.isProd ? '30s' : '10s') return c.json(parseZodSchema(schema.userSelectSchema, user)) }) } ================================================ FILE: apps/api/src/api-v1/users/schemas.ts ================================================ import { z } from '@hono/zod-openapi' import { userIdSchema } from '@/db' export const userIdParamsSchema = z.object({ userId: userIdSchema.openapi({ param: { description: 'User ID', name: 'userId', in: 'path' } }) }) ================================================ FILE: apps/api/src/api-v1/users/update-user.ts ================================================ import { assert, parseZodSchema } from '@agentic/platform-core' import { createRoute, type OpenAPIHono } from '@hono/zod-openapi' import type { AuthenticatedHonoEnv } from '@/lib/types' import { db, eq, schema } from '@/db' import { acl } from '@/lib/acl' import { openapiAuthenticatedSecuritySchemas, openapiErrorResponse404, openapiErrorResponses } from '@/lib/openapi-utils' import { userIdParamsSchema } from './schemas' const route = createRoute({ description: 'Updates a user by ID.', tags: ['users'], operationId: 'updateUser', method: 'post', path: 'users/{userId}', security: openapiAuthenticatedSecuritySchemas, request: { params: userIdParamsSchema, body: { required: true, content: { 'application/json': { schema: schema.userUpdateSchema } } } }, responses: { 200: { description: 'A user object', content: { 'application/json': { schema: schema.userSelectSchema } } }, ...openapiErrorResponses, ...openapiErrorResponse404 } }) export function registerV1UpdateUser(app: OpenAPIHono) { return app.openapi(route, async (c) => { const { userId } = c.req.valid('param') await acl(c, { userId }, { label: 'User' }) const body = c.req.valid('json') const [user] = await db .update(schema.users) .set(body) .where(eq(schema.users.id, userId)) .returning() assert(user, 404, `User not found "${userId}"`) return c.json(parseZodSchema(schema.userSelectSchema, user)) }) } ================================================ FILE: apps/api/src/api-v1/webhooks/stripe-webhook.ts ================================================ import type Stripe from 'stripe' import { assert, HttpError } from '@agentic/platform-core' import type { HonoApp } from '@/lib/types' import { and, db, eq, getStripePriceIdForPricingPlanLineItem, type RawConsumer, type RawDeployment, type RawProject, schema } from '@/db' import { setConsumerStripeSubscriptionStatus } from '@/lib/consumers/utils' import { env } from '@/lib/env' import { stripe } from '@/lib/external/stripe' const relevantStripeEvents = new Set([ // Stripe Checkout Sessions 'checkout.session.completed', // TODO: Handle these events // 'checkout.session.expired', // 'checkout.session.async_payment_failed', // 'checkout.session.async_payment_succeeded', // Stripe Subscriptions 'customer.subscription.created', // TODO: Test these events which should be able to all use the same code path 'customer.subscription.updated', 'customer.subscription.paused', 'customer.subscription.resumed', 'customer.subscription.deleted' // TODO: Handle these events // 'customer.subscription.pending_update_applied', // 'customer.subscription.pending_update_expired', // 'customer.subscription.trial_will_end' ]) export function registerV1StripeWebhook(app: HonoApp) { return app.post('webhooks/stripe', async (ctx) => { const logger = ctx.get('logger') const body = await ctx.req.text() const signature = ctx.req.header('Stripe-Signature') assert( signature, 400, 'error invalid stripe webhook event: missing signature' ) let event: Stripe.Event try { event = stripe.webhooks.constructEvent( body, signature, env.STRIPE_WEBHOOK_SECRET ) } catch (err) { throw new HttpError({ message: 'error invalid stripe webhook event: signature mismatch', cause: err, statusCode: 400 }) } // Shouldn't ever happen because the signatures _should_ be different, but // it's a useful sanity check just in case. assert( event.livemode === env.isStripeLive, 400, 'error invalid stripe webhook event: livemode mismatch' ) if (!relevantStripeEvents.has(event.type)) { return ctx.json({ status: 'ok' }) } logger.info('stripe webhook', event.type, event.data?.object) try { switch (event.type) { case 'checkout.session.completed': { const checkoutSession = event.data.object const { subscription: subscriptionOrId } = checkoutSession assert(subscriptionOrId, 400, 'missing subscription') const { consumerId, plan, userId, projectId, deploymentId } = checkoutSession.metadata ?? {} assert(consumerId, 400, 'missing metadata.consumerId') assert(plan !== undefined, 400, 'missing metadata.plan') const subscriptionId = typeof subscriptionOrId === 'string' ? subscriptionOrId : subscriptionOrId.id const [subscription, consumer, deployment] = await Promise.all([ // Make sure we have the full subscription instead of just the id typeof subscriptionOrId === 'string' ? stripe.subscriptions.retrieve(subscriptionId) : subscriptionOrId, db.query.consumers.findFirst({ where: and(eq(schema.consumers.id, consumerId)), with: { project: true } }), deploymentId ? db.query.deployments.findFirst({ where: and(eq(schema.deployments.id, deploymentId)) }) : undefined ]) assert( subscription, 404, `stripe subscription "${subscriptionId}" not found` ) assert(consumer, 404, `consumer "${consumerId}" not found`) if (deploymentId) { assert(deployment, 404, `deployment "${deploymentId}" not found`) } const { project } = consumer assert(project, 404, `project "${projectId}" not found`) // TODO: Treat this as a transaction... await Promise.all([ // Ensure the underlying Stripe Subscription has all the necessary // metadata stripe.subscriptions.update(subscription.id, { metadata: { ...subscription.metadata, ...checkoutSession.metadata } }), // Sync our Consumer's state with the Stripe Subscription's state syncConsumerWithStripeSubscription({ consumer, deployment, project, subscription, plan, userId, projectId, deploymentId }) ]) break } case 'customer.subscription.created': { // Stripe Checkout-created subscriptions won't have the metadata // necessary to identify the consumer, so ignore this event for now. const subscription = event.data.object const { consumerId, userId, projectId, deploymentId, plan } = subscription.metadata // TODO: This should be coming from Stripe Checkout, and a subsequent // webhook event should record the subscription and initialize the // consumer, but it feels wrong to me to just be logging and ignore // this event. In the future, if we support both Stripe Checkout and // non-Stripe Checkout-based subscription flows, then this codepath // should act very similarly to `customer.subscription.updated`. if ( !consumerId || !userId || !projectId || !deploymentId || plan === undefined ) { break } // Intentional fallthrough } case 'customer.subscription.paused': case 'customer.subscription.resumed': case 'customer.subscription.deleted': case 'customer.subscription.updated': { // https://docs.stripe.com/billing/subscriptions/overview#subscription-statuses const subscription = event.data.object const { consumerId, userId, projectId, deploymentId, plan } = subscription.metadata assert(consumerId, 'missing metadata.consumerId') assert(plan !== undefined, 400, 'missing metadata.plan') logger.info('stripe webhook', event.type, { consumerId, userId, projectId, deploymentId, plan, status: subscription.status }) const [consumer, deployment] = await Promise.all([ db.query.consumers.findFirst({ where: eq(schema.consumers.id, consumerId), with: { project: true } }), deploymentId ? db.query.deployments.findFirst({ where: and(eq(schema.deployments.id, deploymentId)) }) : undefined ]) assert(consumer, 404, `consumer "${consumerId}" not found`) if (deploymentId) { assert(deployment, 404, `deployment "${deploymentId}" not found`) } const { project } = consumer // Sync our Consumer's state with the Stripe Subscription's state await syncConsumerWithStripeSubscription({ consumer, deployment, project, subscription, plan, userId, projectId, deploymentId }) break } default: logger.warn( `unexpected unhandled event "${event.id}" type "${event.type}"`, event.data?.object ) } } catch (err: any) { throw new HttpError({ message: `error processing stripe webhook event "${event.id}" type "${event.type}": ${err.message}`, cause: err.cause ?? err, statusCode: err.statusCode ?? err }) } return ctx.json({ status: 'ok' }) }) } /** * Sync our database Consumer's state with the Stripe Subscription's state. * * For anything billing-related, Stripe's resources is always considered the * single source of truth. Our database's `Consumer` state should always be * derived from the corresponding Stripe subscription. */ export async function syncConsumerWithStripeSubscription({ consumer, project, deployment, subscription, plan, userId, projectId, deploymentId }: { consumer: RawConsumer project: RawProject deployment?: RawDeployment subscription: Stripe.Subscription plan: string | null | undefined userId?: string projectId?: string deploymentId?: string }): Promise { // These extra checks aren't really necessary, but they're nice sanity checks // to ensure metadata consistency with our consumer assert( consumer.userId === userId, 400, `consumer "${consumer.id}" user "${consumer.userId}" does not match stripe checkout metadata user "${userId}"` ) assert( consumer.projectId === projectId, 400, `consumer "${consumer.id}" project "${consumer.projectId}" does not match stripe checkout metadata project "${projectId}"` ) consumer._stripeSubscriptionId = subscription.id consumer.stripeStatus = subscription.status consumer.plan = plan as any // TODO: types setConsumerStripeSubscriptionStatus(consumer) if (deploymentId) { consumer.deploymentId = deploymentId } const pricingPlan = plan ? deployment?.pricingPlans.find((p) => p.slug === plan) : undefined if (pricingPlan) { for (const lineItem of pricingPlan.lineItems) { const stripeSubscriptionItemId = consumer._stripeSubscriptionItemIdMap[lineItem.slug] const stripePriceId: string | undefined = stripeSubscriptionItemId ? undefined : await getStripePriceIdForPricingPlanLineItem({ pricingPlan, pricingPlanLineItem: lineItem, project }) const stripeSubscriptionItem: Stripe.SubscriptionItem | undefined = subscription.items.data.find((item) => stripeSubscriptionItemId ? item.id === stripeSubscriptionItemId : item.price.id === stripePriceId ) assert( stripeSubscriptionItem, 500, `Error post-processing stripe subscription "${subscription.id}" for line-item "${lineItem.slug}" on plan "${pricingPlan.slug}"` ) consumer._stripeSubscriptionItemIdMap[lineItem.slug] = stripeSubscriptionItem.id assert( consumer._stripeSubscriptionItemIdMap[lineItem.slug], 500, `Error post-processing stripe subscription "${subscription.id}" for line-item "${lineItem.slug}" on plan "${pricingPlan.slug}"` ) } } const [updatedConsumer] = await db .update(schema.consumers) .set(consumer) .where(eq(schema.consumers.id, consumer.id)) .returning() assert(updatedConsumer, 500, `consumer "${consumer.id}" not found`) // TODO: invoke provider webhooks // event.data.customer = consumer.getPublicDocument() // await invokeWebhooks(consumer.project, event) return updatedConsumer } ================================================ FILE: apps/api/src/db/index.ts ================================================ import { drizzle } from '@fisch0920/drizzle-orm/postgres-js' import postgres from 'postgres' import { env } from '@/lib/env' import * as schema from './schema' type PostgresClient = ReturnType let _postgresClient: PostgresClient | undefined const postgresClient = _postgresClient ?? (_postgresClient = postgres(env.DATABASE_URL)) export const db = drizzle({ client: postgresClient, schema }) export * as schema from './schema' export { createIdForModel, idMaxLength, idPrefixMap, type ModelType } from './schema/common' export * from './schemas' export type * from './types' export * from './utils' export { and, arrayContained, arrayContains, arrayOverlaps, asc, between, desc, eq, exists, gt, gte, ilike, inArray, isNotNull, isNull, like, lt, lte, ne, not, notBetween, notExists, notIlike, notInArray, notLike, or } from '@fisch0920/drizzle-orm' ================================================ FILE: apps/api/src/db/schema/account.ts ================================================ import { relations } from '@fisch0920/drizzle-orm' import { index, pgTable, text, timestamp } from '@fisch0920/drizzle-orm/pg-core' import { userIdSchema } from '../schemas' import { accountPrimaryId, authProviderTypeEnum, createSelectSchema, timestamps, userId } from './common' import { users } from './user' export const accounts = pgTable( 'accounts', { ...accountPrimaryId, ...timestamps, userId: userId() .notNull() .references(() => users.id, { onDelete: 'cascade' }), provider: authProviderTypeEnum().notNull(), /** Provider-specific account ID (or email in the case of `password` provider) */ accountId: text().notNull(), password: text(), /** Provider-specific username */ accountUsername: text(), /** Standard OAuth2 access token */ accessToken: text(), /** Standard OAuth2 refresh token */ refreshToken: text(), /** Standard OAuth2 access token expires at */ accessTokenExpiresAt: timestamp(), /** Standard OAuth2 refresh token expires at */ refreshTokenExpiresAt: timestamp(), /** OAuth scope(s) */ scope: text() }, (table) => [ index('account_provider_idx').on(table.provider), index('account_userId_idx').on(table.userId), index('account_createdAt_idx').on(table.createdAt), index('account_updatedAt_idx').on(table.updatedAt), index('account_deletedAt_idx').on(table.deletedAt) ] ) export const accountsRelations = relations(accounts, ({ one }) => ({ user: one(users, { fields: [accounts.userId], references: [users.id] }) })) export const accountSelectSchema = createSelectSchema(accounts, { userId: userIdSchema }) .omit({ password: true, accessToken: true, refreshToken: true, accessTokenExpiresAt: true, refreshTokenExpiresAt: true }) .strip() .openapi('Account') ================================================ FILE: apps/api/src/db/schema/auth-data.ts ================================================ import { jsonb, pgTable, text, timestamp } from '@fisch0920/drizzle-orm/pg-core' import { timestamps } from './common' // Simple key-value store of JSON data for OpenAuth-related state. // TODO: remove this and/or replace this with non-openauth version export const authData = pgTable('auth_data', { // Example ID keys: // "oauth:refresh\u001fuser:f99d3004946f9abb\u001f2cae301e-3fdc-40c4-8cda-83b25a616d06" // "signing:key\u001ff001a516-838d-4c88-aa9e-719d8fc9d5a3" // "email\u001ft@t.com\u001fpassword" // "encryption:key\u001f14d3c324-f9c7-4867-81a9-b0b77b0db0be" id: text().primaryKey(), ...timestamps, value: jsonb().$type>().notNull(), expiry: timestamp() }) ================================================ FILE: apps/api/src/db/schema/common.test.ts ================================================ import { expect, test } from 'vitest' import { getIdSchemaForModelType } from '../schemas' import { createIdForModel, idMaxLength, idPrefixMap, type ModelType } from './common' for (const modelType of Object.keys(idPrefixMap)) { test(`${modelType} id`, () => { for (let i = 0; i < 100; ++i) { const id = createIdForModel(modelType as ModelType) expect(id.startsWith(idPrefixMap[modelType as ModelType])).toBe(true) expect(id.length).toBeLessThanOrEqual(idMaxLength) expect(getIdSchemaForModelType(modelType as ModelType).parse(id)).toBe(id) } }) } ================================================ FILE: apps/api/src/db/schema/common.ts ================================================ import { assert } from '@agentic/platform-core' import { type Equal, sql, type Writable } from '@fisch0920/drizzle-orm' import { pgEnum, type PgTimestampBuilderInitial, type PgTimestampConfig, type PgTimestampStringBuilderInitial, type PgVarcharBuilderInitial, type PgVarcharConfig, timestamp as timestampImpl, varchar } from '@fisch0920/drizzle-orm/pg-core' import { createSchemaFactory } from '@fisch0920/drizzle-zod' import { z } from '@hono/zod-openapi' import { createId as createCuid2 } from '@paralleldrive/cuid2' export const namespaceMaxLength = 256 as const export const projectSlugMaxLength = 256 as const export const projectNameMaxLength = 1024 as const // prefix is max 5 characters // separator is 1 character // cuid2 is max 24 characters // so use 32 characters to be safe for storing ids export const idMaxLength = 32 as const export const idPrefixMap = { team: 'team', project: 'proj', deployment: 'depl', consumer: 'csmr', logEntry: 'log', // auth user: 'user', account: 'acct' } as const export type ModelType = keyof typeof idPrefixMap export function createIdForModel(modelType: ModelType): string { const prefix = idPrefixMap[modelType] assert(prefix, 500, `Invalid model type: ${modelType}`) return `${prefix}_${createCuid2()}` } /** * Returns the primary `id` key to use for a given model type. */ function getPrimaryId(modelType: ModelType) { return { id: id() .primaryKey() .$defaultFn(() => createIdForModel(modelType)) } } export const projectPrimaryId = getPrimaryId('project') export const deploymentPrimaryId = getPrimaryId('deployment') export const consumerPrimaryId = getPrimaryId('consumer') export const logEntryPrimaryId = getPrimaryId('logEntry') export const teamPrimaryId = getPrimaryId('team') export const userPrimaryId = getPrimaryId('user') export const accountPrimaryId = getPrimaryId('account') /** * All of our model primary ids have the following format: * * `${modelPrefix}_${cuid2}` */ export function id>( config?: PgVarcharConfig, never> ): PgVarcharBuilderInitial<'', Writable, typeof idMaxLength> { return varchar({ length: idMaxLength, ...config }) } export const projectId = id export const deploymentId = id export const consumerId = id export const logEntryId = id export const teamId = id export const userId = id export function stripeId>( config?: PgVarcharConfig, never> ): PgVarcharBuilderInitial<'', Writable, 255> { return varchar({ length: 255, ...config }) } /** * `namespace/project-slug` */ export function projectIdentifier< U extends string, T extends Readonly<[U, ...U[]]> >( config?: PgVarcharConfig, never> ): PgVarcharBuilderInitial<'', Writable, 514> { return varchar({ length: 514, ...config }) } /** * `namespace/project-slug@hash` */ export function deploymentIdentifier< U extends string, T extends Readonly<[U, ...U[]]> >( config?: PgVarcharConfig, never> ): PgVarcharBuilderInitial<'', Writable, 530> { return varchar({ length: 530, ...config }) } export function username>( config?: PgVarcharConfig, never> ): PgVarcharBuilderInitial<'', Writable, typeof namespaceMaxLength> { return varchar({ length: namespaceMaxLength, ...config }) } export function teamSlug>( config?: PgVarcharConfig, never> ): PgVarcharBuilderInitial<'', Writable, typeof namespaceMaxLength> { return varchar({ length: namespaceMaxLength, ...config }) } export function projectNamespace< U extends string, T extends Readonly<[U, ...U[]]> >( config?: PgVarcharConfig, never> ): PgVarcharBuilderInitial<'', Writable, typeof namespaceMaxLength> { return varchar({ length: namespaceMaxLength, ...config }) } export function projectSlug>( config?: PgVarcharConfig, never> ): PgVarcharBuilderInitial<'', Writable, typeof projectSlugMaxLength> { return varchar({ length: projectSlugMaxLength, ...config }) } export function projectName>( config?: PgVarcharConfig, never> ): PgVarcharBuilderInitial<'', Writable, typeof projectNameMaxLength> { return varchar({ length: projectNameMaxLength, ...config }) } /** * Timestamp with mode `string` */ export function timestamp< TMode extends PgTimestampConfig['mode'] & {} = 'string' >( config?: PgTimestampConfig ): Equal extends true ? PgTimestampStringBuilderInitial<''> : PgTimestampBuilderInitial<''> { return timestampImpl({ mode: 'string' as unknown as TMode, withTimezone: true, ...config }) } export const timestamps = { createdAt: timestamp().notNull().defaultNow(), updatedAt: timestamp() .notNull() .default(sql`now()`), deletedAt: timestamp() } export const userRoleEnum = pgEnum('UserRole', ['user', 'admin']) export const teamMemberRoleEnum = pgEnum('TeamMemberRole', ['user', 'admin']) export const logEntryTypeEnum = pgEnum('LogEntryType', ['log']) export const logEntryLevelEnum = pgEnum('LogEntryLevel', [ 'trace', 'debug', 'info', 'warn', 'error' ]) export const pricingIntervalEnum = pgEnum('PricingInterval', [ 'day', 'week', 'month', 'year' ]) export const pricingCurrencyEnum = pgEnum('PricingCurrency', ['usd']) export const authProviderTypeEnum = pgEnum('AuthProviderType', [ 'github', 'password' ]) export const { createInsertSchema, createSelectSchema, createUpdateSchema } = createSchemaFactory({ zodInstance: z, coerce: { // Coerce dates / strings to timetamps date: true } }) ================================================ FILE: apps/api/src/db/schema/consumer.ts ================================================ import { type StripeSubscriptionItemIdMap, stripeSubscriptionItemIdMapSchema } from '@agentic/platform-types' import { relations } from '@fisch0920/drizzle-orm' import { boolean, index, jsonb, pgTable, text } from '@fisch0920/drizzle-orm/pg-core' import { z } from '@hono/zod-openapi' import { env } from '@/lib/env' import { consumerIdSchema, deploymentIdSchema, projectIdSchema, userIdSchema } from '../schemas' import { consumerPrimaryId, createInsertSchema, createSelectSchema, createUpdateSchema, deploymentId, projectId, stripeId, timestamps, userId } from './common' import { deployments, deploymentSelectSchema } from './deployment' import { projects, projectSelectSchema } from './project' import { users, userSelectSchema } from './user' // TODO: Consumers should be valid for any enabled project like in RapidAPI and GCP. // This may require a separate model to aggregate User Applications. // https://docs.rapidapi.com/docs/keys#section-different-api-keys-per-application /** * A `Consumer` represents a user who has subscribed to a `Project` and is used * to track usage and billing. * * Consumers are linked to a corresponding Stripe Customer and Subscription. * The Stripe customer will either be the user's default Stripe Customer if the * project uses the default Agentic platform account, or a customer on the project * owner's connected Stripe account if the project has Stripe Connect enabled. */ export const consumers = pgTable( 'consumers', { ...consumerPrimaryId, ...timestamps, // API key for this consumer // (called "token" for backwards compatibility) token: text().notNull(), // The slug of the PricingPlan in the target deployment that this consumer // is subscribed to. plan: text(), // Whether the consumer has made at least one successful API call after // initializing their subscription. activated: boolean().default(false).notNull(), // TODO: Re-add coupon support // coupon: text(), // only used during initial creation source: text(), userId: userId() .notNull() .references(() => users.id), // The project this user is subscribed to projectId: projectId() .notNull() .references(() => projects.id, { onDelete: 'cascade' }), // The specific deployment this user is subscribed to, since pricing can // change across deployment versions) deploymentId: deploymentId() .notNull() .references(() => deployments.id, { onDelete: 'cascade' }), // Stripe subscription status (synced via webhooks). Should move from // `incomplete` to `active` after the first successful payment. stripeStatus: text().default('incomplete').notNull(), // Whether the consumer's subscription is currently active, depending on // `stripeStatus`. isStripeSubscriptionActive: boolean().default(true).notNull(), // Main Stripe Subscription id _stripeSubscriptionId: stripeId(), // [pricingPlanLineItemSlug: string]: string _stripeSubscriptionItemIdMap: jsonb() .$type() .default({}) .notNull(), // Denormalized from User or possibly separate for stripe connect // TODO: is this necessary? _stripeCustomerId: stripeId().notNull() }, (table) => [ index('consumer_token_idx').on(table.token), index('consumer_userId_idx').on(table.userId), index('consumer_projectId_idx').on(table.projectId), index('consumer_deploymentId_idx').on(table.deploymentId), index('consumer_isStripeSubscriptionActive_idx').on( table.isStripeSubscriptionActive ), index('consumer_createdAt_idx').on(table.createdAt), index('consumer_updatedAt_idx').on(table.updatedAt), index('consumer_deletedAt_idx').on(table.deletedAt) ] ) export const consumersRelations = relations(consumers, ({ one }) => ({ user: one(users, { fields: [consumers.userId], references: [users.id] }), project: one(projects, { fields: [consumers.projectId], references: [projects.id] }), deployment: one(deployments, { fields: [consumers.deploymentId], references: [deployments.id] }) })) export const consumerSelectBaseSchema = createSelectSchema(consumers, { id: consumerIdSchema, userId: userIdSchema, projectId: projectIdSchema, deploymentId: deploymentIdSchema, _stripeSubscriptionItemIdMap: stripeSubscriptionItemIdMapSchema }) .omit({ _stripeSubscriptionId: true, _stripeSubscriptionItemIdMap: true, _stripeCustomerId: true }) .extend({ user: z .lazy(() => userSelectSchema) .optional() .openapi('User', { type: 'object' }), project: z .lazy(() => projectSelectSchema) .optional() .openapi('Project', { type: 'object' }), // deployment: z // .lazy(() => deploymentSelectSchema) // .optional() // .openapi('Deployment', { type: 'object' }) // TODO: Improve the self-referential typing here that `@hono/zod-openapi` // trips up on. deployment: z .any() .refine( (deployment): boolean => !deployment || deploymentSelectSchema.safeParse(deployment).success, { message: 'Invalid lastDeployment' } ) .transform((deployment): any => { if (!deployment) return undefined return deploymentSelectSchema.parse(deployment) }) .optional() }) // These are all derived virtual URLs that are not stored in the database export const derivedConsumerFields = { /** * A private admin URL for managing the customer's subscription. This URL * is only accessible by the customer. * * @example https://agentic.so/app/consumers/cons_123 */ adminUrl: z .string() .url() .describe( "A private admin URL for managing the customer's subscription. This URL is only accessible by the customer." ) } as const export const consumerSelectSchema = consumerSelectBaseSchema .transform((consumer) => ({ ...consumer, adminUrl: `${env.AGENTIC_WEB_BASE_URL}/app/consumers/${consumer.id}` })) .pipe(consumerSelectBaseSchema.extend(derivedConsumerFields).strip()) .describe( `A Consumer represents a user who has subscribed to a Project and is used to track usage and billing. Consumers are linked to a corresponding Stripe Customer and Subscription. The Stripe customer will either be the user's default Stripe Customer if the project uses the default Agentic platform account, or a customer on the project owner's connected Stripe account if the project has Stripe Connect enabled.` ) .openapi('Consumer') export const consumerAdminSelectSchema = consumerSelectBaseSchema .extend({ _stripeCustomerId: z.string().nonempty() }) .transform((consumer) => ({ ...consumer, adminUrl: `${env.AGENTIC_WEB_BASE_URL}/app/consumers/${consumer.id}` })) .openapi('AdminConsumer') export const consumerInsertSchema = createInsertSchema(consumers, { deploymentId: deploymentIdSchema.optional(), plan: z.string().nonempty() }) .pick({ plan: true, source: true, deploymentId: true }) .strict() export const consumerUpdateSchema = createUpdateSchema(consumers, { deploymentId: deploymentIdSchema.optional() }) .pick({ plan: true, deploymentId: true }) .strict() ================================================ FILE: apps/api/src/db/schema/deployment.ts ================================================ import { agenticProjectConfigSchema, defaultRequestsRateLimit, type OriginAdapter, type PricingPlanList, type RateLimit, resolvedAgenticProjectConfigSchema, type Tool, type ToolConfig } from '@agentic/platform-types' import { isValidDeploymentHash, parseDeploymentIdentifier } from '@agentic/platform-validators' import { relations } from '@fisch0920/drizzle-orm' import { boolean, index, jsonb, pgTable, text, uniqueIndex } from '@fisch0920/drizzle-orm/pg-core' import { z } from '@hono/zod-openapi' import { env } from '@/lib/env' import { deploymentIdentifierSchema, deploymentIdSchema, projectIdSchema, teamIdSchema, userIdSchema } from '../schemas' import { createSelectSchema, createUpdateSchema, deploymentIdentifier, deploymentPrimaryId, pricingIntervalEnum, projectId, projectName, teamId, timestamps, userId } from './common' import { projects, projectSelectSchema } from './project' import { teams } from './team' import { users } from './user' /** * A Deployment is a single, immutable instance of a Project. Each deployment * contains pricing plans, origin server config (OpenAPI or MCP server), tool * definitions, and metadata. * * Deployments are private to a developer or team until they are published, at * which point they are accessible to any customers with access to the parent * Project. */ export const deployments = pgTable( 'deployments', { ...deploymentPrimaryId, ...timestamps, identifier: deploymentIdentifier().unique().notNull(), hash: text().notNull(), version: text(), published: boolean().default(false).notNull(), // display name name: projectName().notNull(), description: text().default('').notNull(), readme: text(), // URL to uploaded markdown document iconUrl: text(), sourceUrl: text(), homepageUrl: text(), userId: userId() .notNull() .references(() => users.id), teamId: teamId().references(() => teams.id), projectId: projectId() .notNull() .references(() => projects.id, { onDelete: 'cascade' }), // Tool definitions exposed by the origin server tools: jsonb().$type().notNull(), // Tool configs customize the behavior of tools for different pricing plans toolConfigs: jsonb().$type().default([]).notNull(), // Origin API adapter config (url, openapi/mcp/raw, internal/external hosting, etc) origin: jsonb().$type().notNull(), // Array pricingPlans: jsonb().$type().notNull(), // Which pricing intervals are supported for subscriptions to this project pricingIntervals: pricingIntervalEnum() .array() .default(['month']) .notNull(), // Default rate limit across all pricing plans defaultRateLimit: jsonb() .$type() .notNull() .default(defaultRequestsRateLimit) // TODO: metadata config (logo, keywords, examples, etc) // TODO: webhooks // TODO: coupons // TODO: third-party auth provider config // NOTE: will need consumer.authProviders as well as user.authProviders for // this because custom oauth credentials that are deployment-specific. will // prolly also need to hash the individual AuthProviders in // deployment.authProviders to compare across deployments. }, (table) => [ uniqueIndex('deployment_identifier_idx').on(table.identifier), index('deployment_userId_idx').on(table.userId), index('deployment_teamId_idx').on(table.teamId), index('deployment_projectId_idx').on(table.projectId), index('deployment_published_idx').on(table.published), index('deployment_version_idx').on(table.version), index('deployment_createdAt_idx').on(table.createdAt), index('deployment_updatedAt_idx').on(table.updatedAt), index('deployment_deletedAt_idx').on(table.deletedAt) ] ) export const deploymentsRelations = relations(deployments, ({ one }) => ({ user: one(users, { fields: [deployments.userId], references: [users.id] }), team: one(teams, { fields: [deployments.teamId], references: [teams.id] }), project: one(projects, { fields: [deployments.projectId], references: [projects.id] }) })) // TODO: virtual hasFreeTier // TODO: virtual url // TODO: virtual openApiUrl // TODO: virtual saasUrl // TODO: virtual authProviders? // TODO: virtual openapi spec? (hide openapi.servers) export const deploymentSelectBaseSchema = createSelectSchema(deployments, { id: deploymentIdSchema, userId: userIdSchema, teamId: teamIdSchema.optional(), projectId: projectIdSchema, identifier: deploymentIdentifierSchema, hash: (schema) => schema.refine((hash) => isValidDeploymentHash(hash), { message: 'Invalid deployment hash' }), name: resolvedAgenticProjectConfigSchema.shape.name, version: resolvedAgenticProjectConfigSchema.shape.version, description: resolvedAgenticProjectConfigSchema.shape.description, readme: resolvedAgenticProjectConfigSchema.shape.readme, iconUrl: resolvedAgenticProjectConfigSchema.shape.iconUrl, sourceUrl: resolvedAgenticProjectConfigSchema.shape.sourceUrl, homepageUrl: resolvedAgenticProjectConfigSchema.shape.homepageUrl, origin: resolvedAgenticProjectConfigSchema.shape.origin, pricingPlans: resolvedAgenticProjectConfigSchema.shape.pricingPlans, pricingIntervals: resolvedAgenticProjectConfigSchema.shape.pricingIntervals, tools: resolvedAgenticProjectConfigSchema.shape.tools, toolConfigs: resolvedAgenticProjectConfigSchema.shape.toolConfigs, defaultRateLimit: resolvedAgenticProjectConfigSchema.shape.defaultRateLimit }) .omit({ origin: true }) .extend({ // user: z // .lazy(() => userSelectSchema) // .optional() // .openapi('User', { type: 'object' }), // team: z // .lazy(() => teamSelectSchema) // .optional() // .openapi('Team', { type: 'object' }), // project: z // .lazy(() => projectSelectSchema) // .optional() // .openapi('Project', { type: 'object' }) // TODO: Improve the self-referential typing here that `@hono/zod-openapi` // trips up on. project: z .any() .refine( (project): boolean => !project || projectSelectSchema.safeParse(project).success, { message: 'Invalid lastDeployment' } ) .transform((project): any => { if (!project) return undefined return projectSelectSchema.parse(project) }) .optional() // .openapi('Project', { type: 'object' }) // TODO: Circular references make this schema less than ideal // project: z.object({}).optional().openapi('Project', { type: 'object' }) }) // These are all derived virtual URLs that are not stored in the database export const derivedDeploymentFields = { /** * The public base HTTP URL for the deployment supporting HTTP POST requests * for individual tools at `/tool-name` subpaths. * * @example https://gateway.agentic.so/@agentic/search@latest */ gatewayBaseUrl: z .string() .url() .describe( 'The public base HTTP URL for the deployment supporting HTTP POST requests for individual tools at `/tool-name` subpaths.' ), /** * The public MCP URL for the deployment supporting the Streamable HTTP * transport. * * @example https://gateway.agentic.so/@agentic/search@latest/mcp */ gatewayMcpUrl: z .string() .url() .describe( 'The public MCP URL for the deployment supporting the Streamable HTTP transport.' ), /** * The public marketplace URL for the deployment's project. * * Note that only published deployments are visible on the marketplace. * * @example https://agentic.so/marketplace/projects/@agentic/search */ marketplaceUrl: z .string() .url() .describe("The public marketplace URL for the deployment's project."), /** * A private admin URL for managing the deployment. This URL is only accessible * by project owners. * * @example https://agentic.so/app/projects/@agentic/search/deployments/123 */ adminUrl: z .string() .url() .describe( 'A private admin URL for managing the deployment. This URL is only accessible by project owners.' ) } as const export const deploymentSelectSchema = deploymentSelectBaseSchema .transform((deployment) => { const { projectIdentifier, deploymentIdentifier } = parseDeploymentIdentifier(deployment.identifier) return { ...deployment, gatewayBaseUrl: `${env.AGENTIC_GATEWAY_BASE_URL}/${deploymentIdentifier}`, gatewayMcpUrl: `${env.AGENTIC_GATEWAY_BASE_URL}/${deploymentIdentifier}/mcp`, marketplaceUrl: `${env.AGENTIC_WEB_BASE_URL}/marketplace/projects/${projectIdentifier}`, adminUrl: `${env.AGENTIC_WEB_BASE_URL}/app/projects/${projectIdentifier}/deployments/${deployment.hash}` } }) .pipe(deploymentSelectBaseSchema.extend(derivedDeploymentFields).strip()) .describe( `A Deployment is a single, immutable instance of a Project. Each deployment contains pricing plans, origin server config (OpenAPI or MCP server), tool definitions, and metadata. Deployments are private to a developer or team until they are published, at which point they are accessible to any customers with access to the parent Project.` ) .openapi('Deployment') export const deploymentAdminSelectSchema = deploymentSelectBaseSchema .extend({ origin: resolvedAgenticProjectConfigSchema.shape.origin, _secret: z.string().nonempty() }) .transform((deployment) => { const { projectIdentifier, deploymentIdentifier } = parseDeploymentIdentifier(deployment.identifier) return { ...deployment, gatewayBaseUrl: `${env.AGENTIC_GATEWAY_BASE_URL}/${deploymentIdentifier}`, gatewayMcpUrl: `${env.AGENTIC_GATEWAY_BASE_URL}/${deploymentIdentifier}/mcp`, marketplaceUrl: `${env.AGENTIC_WEB_BASE_URL}/marketplace/projects/${projectIdentifier}`, adminUrl: `${env.AGENTIC_WEB_BASE_URL}/app/projects/${projectIdentifier}/deployments/${deployment.hash}` } }) .openapi('AdminDeployment') export const deploymentInsertSchema = agenticProjectConfigSchema.strict() // TODO: Deployments should be immutable, so we should not allow updates aside // from publishing. But editing a project's description should be possible from // the admin UI, so maybe we allow only updates to some properties? Or we // denormalize these fields in `project`? export const deploymentUpdateSchema = createUpdateSchema(deployments) .pick({ deletedAt: true, description: true }) .strict() export const deploymentPublishSchema = createUpdateSchema(deployments, { version: z.string().nonempty() }) .pick({ version: true }) .strict() ================================================ FILE: apps/api/src/db/schema/index.ts ================================================ export * from './account' export * from './auth-data' export * from './common' export * from './consumer' export * from './deployment' export * from './log-entry' export * from './project' export * from './team' export * from './team-member' export * from './user' ================================================ FILE: apps/api/src/db/schema/log-entry.ts ================================================ import { relations } from '@fisch0920/drizzle-orm' import { index, jsonb, pgTable, text, varchar } from '@fisch0920/drizzle-orm/pg-core' import { consumerIdSchema, deploymentIdSchema, projectIdSchema, userIdSchema } from '../schemas' import { consumerId, createSelectSchema, deploymentId, logEntryLevelEnum, logEntryPrimaryId, logEntryTypeEnum, projectId, timestamps, userId } from './common' import { consumers } from './consumer' import { deployments } from './deployment' import { projects } from './project' import { users } from './user' /** * A `LogEntry` is an internal audit log entry. */ export const logEntries = pgTable( 'log_entries', { ...logEntryPrimaryId, ...timestamps, // core data (required) type: logEntryTypeEnum().notNull().default('log'), level: logEntryLevelEnum().notNull().default('info'), message: text().notNull(), // context info (required) environment: text(), service: text(), requestId: varchar({ length: 512 }), traceId: varchar({ length: 512 }), // relations (optional) userId: userId(), projectId: projectId(), deploymentId: deploymentId(), consumerId: consumerId(), // misc metadata (optional) metadata: jsonb().$type>().default({}).notNull() }, (table) => [ index('log_entry_type_idx').on(table.type), // TODO: Don't add these extra indices until we need them. They'll become // very large very fast. // index('log_entry_level_idx').on(table.level), // index('log_entry_environment_idx').on(table.environment), // index('log_entry_service_idx').on(table.service), // index('log_entry_requestId_idx').on(table.requestId), // index('log_entry_traceId_idx').on(table.traceId), index('log_entry_userId_idx').on(table.userId), index('log_entry_projectId_idx').on(table.projectId), index('log_entry_deploymentId_idx').on(table.deploymentId), // index('log_entry_consumerId_idx').on(table.consumerId), index('log_entry_createdAt_idx').on(table.createdAt), index('log_entry_updatedAt_idx').on(table.updatedAt), index('log_entry_deletedAt_idx').on(table.deletedAt) ] ) export const logEntriesRelations = relations(logEntries, ({ one }) => ({ user: one(users, { fields: [logEntries.userId], references: [users.id] }), project: one(projects, { fields: [logEntries.projectId], references: [projects.id] }), deployment: one(deployments, { fields: [logEntries.deploymentId], references: [deployments.id] }), consumer: one(consumers, { fields: [logEntries.consumerId], references: [consumers.id] }) })) export const logEntrySelectSchema = createSelectSchema(logEntries, { userId: userIdSchema.optional(), projectId: projectIdSchema.optional(), deploymentId: deploymentIdSchema.optional(), consumerId: consumerIdSchema.optional() }) // .extend({ // user: z // .lazy(() => userSelectSchema) // .optional() // .openapi('User', { type: 'object' }), // project: z // .lazy(() => projectSelectSchema) // .optional() // .openapi('Project', { type: 'object' }), // deployment: z // .lazy(() => deploymentSelectSchema) // .optional() // .openapi('Deployment', { type: 'object' }), // consumer: z // .lazy(() => consumerSelectSchema) // .optional() // .openapi('Consumer', { type: 'object' }) // }) .strip() .openapi('LogEntry') ================================================ FILE: apps/api/src/db/schema/project.ts ================================================ import { agenticProjectConfigSchema, pricingIntervalSchema, type StripeMeterIdMap, stripeMeterIdMapSchema, type StripePriceIdMap, stripePriceIdMapSchema, type StripeProductIdMap, stripeProductIdMapSchema } from '@agentic/platform-types' import { relations } from '@fisch0920/drizzle-orm' import { boolean, index, integer, jsonb, pgTable, text, uniqueIndex } from '@fisch0920/drizzle-orm/pg-core' import { z } from '@hono/zod-openapi' import { env } from '@/lib/env' import { deploymentIdSchema, projectIdentifierSchema, projectIdSchema, teamIdSchema, userIdSchema } from '../schemas' import { createInsertSchema, createSelectSchema, createUpdateSchema, deploymentId, pricingCurrencyEnum, pricingIntervalEnum, projectIdentifier, projectName, projectNamespace, projectPrimaryId, projectSlug, stripeId, teamId, timestamps, userId } from './common' import { deployments, deploymentSelectSchema } from './deployment' import { teams, teamSelectSchema } from './team' import { users, userSelectSchema } from './user' /** * A Project represents a single Agentic API product. Is is comprised of a * series of immutable Deployments, each of which contains pricing data, origin * API config, OpenAPI or MCP specs, tool definitions, and various metadata. * * You can think of Agentic Projects as similar to Vercel projects. They both * hold some common configuration and are comprised of a series of immutable * Deployments. * * Internally, Projects manage all of the Stripe billing resources across * Deployments (Stripe Products, Prices, and Meters for usage-based billing). */ export const projects = pgTable( 'projects', { ...projectPrimaryId, ...timestamps, // display name name: projectName().notNull(), // identifier is `@namespace/slug` identifier: projectIdentifier().unique().notNull(), // namespace is either a username or team slug namespace: projectNamespace().notNull(), // slug is a unique identifier for the project within its namespace slug: projectSlug().notNull(), // Defaulting to `true` for now to hide all projects from the marketplace // by default. Will need to manually set to `true` to allow projects to be // visible on the marketplace. private: boolean().default(true).notNull(), // Admin-controlled tags for organizing and featuring on the marketplace tags: text().array(), // TODO: allow for multiple aliases like vercel // alias: text(), userId: userId() .notNull() .references(() => users.id), teamId: teamId(), // Most recently published Deployment if one exists lastPublishedDeploymentId: deploymentId(), // Most recent Deployment if one exists lastDeploymentId: deploymentId(), // Semver version of the most recently published Deployment (if one exists) // (denormalized for convenience) lastPublishedDeploymentVersion: text(), applicationFeePercent: integer().default(20).notNull(), // TODO: This is going to need to vary from dev to prod //isStripeConnectEnabled: boolean().default(false).notNull(), // Default pricing interval for subscriptions to this project // Note: This is essentially hard-coded and not configurable by users for now. defaultPricingInterval: pricingIntervalEnum().default('month').notNull(), // Pricing currency used across all prices and subscriptions to this project pricingCurrency: pricingCurrencyEnum().default('usd').notNull(), // All deployments share the same underlying proxy secret, which allows // origin servers to verify that requests are coming from Agentic's API // gateway. _secret: text().notNull(), // Auth token used to access the platform API on behalf of this project // _providerToken: text().notNull(), // TODO: Full-text search // _text: text().default('').notNull(), // Stripe coupons associated with this project, mapping from unique coupon // object hash to stripe coupon id. // `[hash: string]: string` // _stripeCouponsMap: jsonb() // .$type>() // .default({}) // .notNull(), // Stripe billing Products associated with this project across deployments, // mapping from PricingPlanLineItem **slug** to Stripe Product id. // NOTE: This map uses slugs as keys, unlike `_stripePriceIdMap`, because // Stripe Products are agnostic to the PricingPlanLineItem config. This is // important for them to be shared across deployments even if the pricing // details change. _stripeProductIdMap: jsonb() .$type() .default({}) .notNull(), // Stripe billing Prices associated with this project, mapping from unique // PricingPlanLineItem **hash** to Stripe Price id. // NOTE: This map uses hashes as keys, because Stripe Prices are dependent // on the PricingPlanLineItem config. This is important for them to be shared // across deployments even if the pricing details change. _stripePriceIdMap: jsonb().$type().default({}).notNull(), // Stripe billing LineItems associated with this project, mapping from unique // PricingPlanLineItem **slug** to Stripe Meter id. // NOTE: This map uses slugs as keys, unlike `_stripePriceIdMap`, because // Stripe Products are agnostic to the PricingPlanLineItem config. This is // important for them to be shared across deployments even if the pricing // details change. _stripeMeterIdMap: jsonb().$type().default({}).notNull(), // Connected Stripe account (standard or express). // If not defined, then subscriptions for this project route through our // main Stripe account. _stripeAccountId: stripeId() }, (table) => [ uniqueIndex('project_identifier_idx').on(table.identifier), index('project_namespace_idx').on(table.namespace), index('project_userId_idx').on(table.userId), index('project_teamId_idx').on(table.teamId), // index('project_alias_idx').on(table.alias), index('project_private_idx').on(table.private), index('project_tags_idx').on(table.tags), index('project_lastPublishedDeploymentId_idx').on( table.lastPublishedDeploymentId ), index('project_createdAt_idx').on(table.createdAt), index('project_updatedAt_idx').on(table.updatedAt), index('project_deletedAt_idx').on(table.deletedAt) ] ) export const projectsRelations = relations(projects, ({ one }) => ({ user: one(users, { fields: [projects.userId], references: [users.id] }), team: one(teams, { fields: [projects.teamId], references: [teams.id] }), lastPublishedDeployment: one(deployments, { fields: [projects.lastPublishedDeploymentId], references: [deployments.id], relationName: 'lastPublishedDeployment' }), lastDeployment: one(deployments, { fields: [projects.lastDeploymentId], references: [deployments.id], relationName: 'lastDeployment' }) // deployments: many(deployments, { // relationName: 'deployments' // }), // publishedDeployments: many(deployments, { // relationName: 'publishedDeployments' // }) })) export const projectSelectBaseSchema = createSelectSchema(projects, { id: projectIdSchema, userId: userIdSchema, teamId: teamIdSchema.optional(), identifier: projectIdentifierSchema, name: agenticProjectConfigSchema.shape.name, slug: agenticProjectConfigSchema.shape.slug, tags: z.array(z.string()).optional(), lastPublishedDeploymentId: deploymentIdSchema.optional(), lastDeploymentId: deploymentIdSchema.optional(), applicationFeePercent: (schema) => schema.nonnegative(), defaultPricingInterval: pricingIntervalSchema, _stripeProductIdMap: stripeProductIdMapSchema, _stripePriceIdMap: stripePriceIdMapSchema, _stripeMeterIdMap: stripeMeterIdMapSchema }) .omit({ applicationFeePercent: true, _secret: true, // _text: true, _stripeProductIdMap: true, _stripePriceIdMap: true, _stripeMeterIdMap: true, _stripeAccountId: true }) .extend({ user: z .lazy(() => userSelectSchema) .optional() .openapi('User', { type: 'object' }), team: z .lazy(() => teamSelectSchema) .optional() .openapi('Team', { type: 'object' }), // TODO: Improve the self-referential typing here that `@hono/zod-openapi` // trips up on. lastPublishedDeployment: z .any() .refine( (deployment): boolean => !deployment || deploymentSelectSchema.safeParse(deployment).success, { message: 'Invalid lastPublishedDeployment' } ) .transform((deployment): any => { if (!deployment) return undefined return deploymentSelectSchema.parse(deployment) }) .optional(), lastDeployment: z .any() .refine( (deployment): boolean => !deployment || deploymentSelectSchema.safeParse(deployment).success, { message: 'Invalid lastDeployment' } ) .transform((deployment): any => { if (!deployment) return undefined return deploymentSelectSchema.parse(deployment) }) .optional(), deployment: z .any() .refine( (deployment): boolean => !deployment || deploymentSelectSchema.safeParse(deployment).success, { message: 'Invalid lastDeployment' } ) .transform((deployment): any => { if (!deployment) return undefined return deploymentSelectSchema.parse(deployment) }) .optional() }) // These are all derived virtual URLs that are not stored in the database export const derivedProjectFields = { /** * The public base HTTP URL for the project supporting HTTP POST requests for * individual tools at `/tool-name` subpaths. * * @example https://gateway.agentic.so/@agentic/search */ gatewayBaseUrl: z .string() .url() .describe( 'The public base HTTP URL for the project supporting HTTP POST requests for individual tools at `/tool-name` subpaths.' ), /** * The public MCP URL for the project supporting the Streamable HTTP transport. * * @example https://gateway.agentic.so/@agentic/search/mcp */ gatewayMcpUrl: z .string() .url() .describe( 'The public MCP URL for the project supporting the Streamable HTTP transport.' ), /** * The public marketplace URL for the project. * * @example https://agentic.so/marketplace/projects/@agentic/search */ marketplaceUrl: z .string() .url() .describe('The public marketplace URL for the project.'), /** * A private admin URL for managing the project. This URL is only accessible * by project owners. * * @example https://agentic.so/app/projects/@agentic/search */ adminUrl: z .string() .url() .describe( 'A private admin URL for managing the project. This URL is only accessible by project owners.' ) } as const export const projectSelectSchema = projectSelectBaseSchema .transform((project) => ({ ...project, gatewayBaseUrl: `${env.AGENTIC_GATEWAY_BASE_URL}/${project.identifier}`, gatewayMcpUrl: `${env.AGENTIC_GATEWAY_BASE_URL}/${project.identifier}/mcp`, marketplaceUrl: `${env.AGENTIC_WEB_BASE_URL}/marketplace/projects/${project.identifier}`, adminUrl: `${env.AGENTIC_WEB_BASE_URL}/app/projects/${project.identifier}` })) .pipe(projectSelectBaseSchema.extend(derivedProjectFields).strip()) .describe( `A Project represents a single Agentic API product. It is comprised of a series of immutable Deployments, each of which contains pricing data, origin API config, OpenAPI or MCP specs, tool definitions, and various metadata. You can think of Agentic Projects as similar to Vercel projects. They both hold some common configuration and are comprised of a series of immutable Deployments. Internally, Projects manage all of the Stripe billing resources across Deployments (Stripe Products, Prices, and Meters for usage-based billing).` ) .openapi('Project') export const projectInsertSchema = createInsertSchema(projects, { identifier: projectIdentifierSchema, name: agenticProjectConfigSchema.shape.name, slug: agenticProjectConfigSchema.shape.slug }) .pick({ name: true, slug: true }) .strict() export const projectUpdateSchema = createUpdateSchema(projects) .pick({ name: true // alias: true }) .strict() // TODO: virtual saasUrl // TODO: virtual aliasUrl ================================================ FILE: apps/api/src/db/schema/team-member.ts ================================================ import { relations } from '@fisch0920/drizzle-orm' import { boolean, index, pgTable, primaryKey } from '@fisch0920/drizzle-orm/pg-core' import { userIdSchema } from '../schemas' import { createInsertSchema, createSelectSchema, createUpdateSchema, teamId, teamMemberRoleEnum, teamSlug, timestamp, timestamps, userId } from './common' import { teams } from './team' import { users } from './user' export const teamMembers = pgTable( 'team_members', { ...timestamps, userId: userId() .notNull() .references(() => users.id, { onDelete: 'cascade' }), teamSlug: teamSlug() .notNull() .references(() => teams.slug, { onDelete: 'cascade' }), teamId: teamId() .notNull() .references(() => teams.id, { onDelete: 'cascade' }), role: teamMemberRoleEnum().default('user').notNull(), confirmed: boolean().default(false).notNull(), confirmedAt: timestamp() }, (table) => [ primaryKey({ columns: [table.userId, table.teamId] }), index('team_member_user_idx').on(table.userId), index('team_member_team_idx').on(table.teamId), index('team_member_slug_idx').on(table.teamSlug), index('team_member_createdAt_idx').on(table.createdAt), index('team_member_updatedAt_idx').on(table.updatedAt), index('team_member_deletedAt_idx').on(table.deletedAt) ] ) export const teamMembersRelations = relations(teamMembers, ({ one }) => ({ user: one(users, { fields: [teamMembers.userId], references: [users.id] }), team: one(teams, { fields: [teamMembers.teamId], references: [teams.id] }) })) export const teamMemberSelectSchema = createSelectSchema(teamMembers) // .extend({ // user: z // .lazy(() => userSelectSchema) // .optional() // .openapi('User', { type: 'object' }), // team: z // .lazy(() => teamSelectSchema) // .optional() // .openapi('Team', { type: 'object' }) // }) .strip() .openapi('TeamMember') export const teamMemberInsertSchema = createInsertSchema(teamMembers, { userId: userIdSchema }) .pick({ userId: true, role: true }) .strict() export const teamMemberUpdateSchema = createUpdateSchema(teamMembers) .pick({ role: true }) .strict() ================================================ FILE: apps/api/src/db/schema/team.ts ================================================ import { isValidTeamSlug } from '@agentic/platform-validators' import { relations } from '@fisch0920/drizzle-orm' import { index, pgTable, text, uniqueIndex } from '@fisch0920/drizzle-orm/pg-core' import { userIdSchema } from '../schemas' import { createInsertSchema, createSelectSchema, createUpdateSchema, teamPrimaryId, teamSlug, timestamps, userId } from './common' import { teamMembers } from './team-member' import { users } from './user' export const teams = pgTable( 'teams', { ...teamPrimaryId, ...timestamps, slug: teamSlug().unique().notNull(), name: text().notNull(), ownerId: userId().notNull() }, (table) => [ uniqueIndex('team_slug_idx').on(table.slug), index('team_createdAt_idx').on(table.createdAt), index('team_updatedAt_idx').on(table.updatedAt), index('team_deletedAt_idx').on(table.deletedAt) ] ) export const teamsRelations = relations(teams, ({ one, many }) => ({ owner: one(users, { fields: [teams.ownerId], references: [users.id] }), members: many(teamMembers) })) export const teamSelectSchema = createSelectSchema(teams) // .extend({ // owner: z // .lazy(() => userSelectSchema) // .optional() // .openapi('User', { type: 'object' }) // }) .strip() .openapi('Team') export const teamInsertSchema = createInsertSchema(teams, { slug: (schema) => schema.refine((slug) => isValidTeamSlug(slug), { message: 'Invalid team slug' }) }) .omit({ id: true, createdAt: true, updatedAt: true, ownerId: true }) .strict() export const teamUpdateSchema = createUpdateSchema(teams, { ownerId: userIdSchema }) .pick({ name: true, ownerId: true }) .strict() ================================================ FILE: apps/api/src/db/schema/user.ts ================================================ import { relations } from '@fisch0920/drizzle-orm' import { boolean, index, pgTable, text, uniqueIndex } from '@fisch0920/drizzle-orm/pg-core' import { accounts } from './account' import { createSelectSchema, createUpdateSchema, stripeId, timestamps, username, // username, userPrimaryId, userRoleEnum } from './common' export const users = pgTable( 'users', { ...userPrimaryId, ...timestamps, username: username().unique().notNull(), role: userRoleEnum().default('user').notNull(), email: text().notNull().unique(), isEmailVerified: boolean().default(false).notNull(), name: text(), bio: text(), image: text(), //isStripeConnectEnabledByDefault: boolean().default(true).notNull(), stripeCustomerId: stripeId() }, (table) => [ uniqueIndex('user_email_idx').on(table.email), uniqueIndex('user_username_idx').on(table.username), index('user_createdAt_idx').on(table.createdAt), index('user_updatedAt_idx').on(table.updatedAt) // index('user_deletedAt_idx').on(table.deletedAt) ] ) export const usersRelations = relations(users, ({ many }) => ({ accounts: many(accounts) })) export const userSelectSchema = createSelectSchema(users) .strip() .openapi('User') export const userUpdateSchema = createUpdateSchema(users) .pick({ name: true, bio: true, image: true //isStripeConnectEnabledByDefault: true }) .strict() ================================================ FILE: apps/api/src/db/schemas.ts ================================================ import { assert } from '@agentic/platform-core' import { isNamespaceAllowed, isValidCuid, isValidDeploymentIdentifier, isValidProjectIdentifier, isValidTeamSlug, isValidUsername } from '@agentic/platform-validators' import { z } from '@hono/zod-openapi' import type { consumersRelations } from './schema/consumer' import type { deploymentsRelations } from './schema/deployment' import type { projectsRelations } from './schema/project' import { idPrefixMap, type ModelType } from './schema/common' export function getIdSchemaForModelType(modelType: ModelType) { const idPrefix = idPrefixMap[modelType] assert(idPrefix, 500, `Invalid model type: ${modelType}`) // Convert model type to PascalCase const modelDisplayName = modelType.charAt(0).toUpperCase() + modelType.slice(1) const example = `${idPrefix}_tz4a98xxat96iws9zmbrgj3a` return z .string() .refine( (id) => { const parts = id.split('_') if (parts.length !== 2) return false if (parts[0] !== idPrefix) return false if (!isValidCuid(parts[1])) return false return true }, { message: `Invalid ${modelDisplayName} id` } ) .describe(`${modelDisplayName} id (e.g. "${example}")`) // TODO: is this necessary? // .openapi(`${modelDisplayName}Id`, { example }) } export const userIdSchema = getIdSchemaForModelType('user') export const teamIdSchema = getIdSchemaForModelType('team') export const consumerIdSchema = getIdSchemaForModelType('consumer') export const projectIdSchema = getIdSchemaForModelType('project') export const deploymentIdSchema = getIdSchemaForModelType('deployment') export const logEntryIdSchema = getIdSchemaForModelType('logEntry') export const projectIdentifierSchema = z .string() .refine( (id) => isValidProjectIdentifier(id, { strict: false }) || projectIdSchema.safeParse(id).success, { message: 'Invalid project identifier' } ) .describe('Public project identifier (e.g. "@namespace/project-slug")') .openapi('ProjectIdentifier') export const deploymentIdentifierSchema = z .string() .refine( (id) => isValidDeploymentIdentifier(id, { strict: false }) || deploymentIdSchema.safeParse(id).success, { message: 'Invalid deployment identifier' } ) .describe( 'Public deployment identifier (e.g. "@namespace/project-slug@{hash|version|latest}")' ) .openapi('DeploymentIdentifier') export const usernameSchema = z .string() .refine((username) => isValidUsername(username), { message: 'Invalid username' }) .refine((username) => isNamespaceAllowed(username), { message: 'Username is not allowed (reserved, offensive, or otherwise confusing)' }) export const teamSlugSchema = z .string() .refine((slug) => isValidTeamSlug(slug), { message: 'Invalid team slug' }) .refine((slug) => isNamespaceAllowed(slug), { message: 'Team slug is not allowed (reserved, offensive, or otherwise confusing)' }) export const paginationSchema = z.object({ offset: z.coerce.number().int().nonnegative().default(0).optional(), limit: z.coerce.number().int().positive().max(100).default(10).optional(), sort: z.enum(['asc', 'desc']).default('desc').optional(), sortBy: z.enum(['createdAt', 'updatedAt']).default('createdAt').optional() }) export type ProjectRelationFields = keyof ReturnType< (typeof projectsRelations)['config'] > export const projectRelationsSchema: z.ZodType = z.enum([ 'user', 'team', 'lastPublishedDeployment', 'lastDeployment' ]) export type DeploymentRelationFields = keyof ReturnType< (typeof deploymentsRelations)['config'] > export const deploymentRelationsSchema: z.ZodType = z.enum(['user', 'team', 'project']) export type ConsumerRelationFields = keyof ReturnType< (typeof consumersRelations)['config'] > export const consumerRelationsSchema: z.ZodType = z.enum(['user', 'project', 'deployment']) ================================================ FILE: apps/api/src/db/types.test.ts ================================================ import type { Simplify } from 'type-fest' import { expectTypeOf, test } from 'vitest' import type { Consumer, LogEntry, RawConsumer, RawConsumerUpdate, RawDeployment, RawLogEntry, RawProject, RawUser, User } from './types' type UserKeys = Exclude type LogEntryKeys = keyof RawLogEntry & keyof LogEntry type ConsumerKeys = keyof RawConsumer & keyof Consumer type TODOFixedConsumer = Simplify< Omit< Consumer, | 'user' | 'project' | 'deployment' | 'gatewayBaseUrl' | 'gatewayMcpUrl' | 'marketplaceUrl' | 'adminUrl' > & { user?: RawUser | null project?: RawProject | null deployment?: RawDeployment | null } > test('User types are compatible', () => { expectTypeOf().toExtend() expectTypeOf().toEqualTypeOf() }) test('LogEntry types are compatible', () => { expectTypeOf().toExtend() expectTypeOf().toEqualTypeOf< RawLogEntry[LogEntryKeys] >() }) test('Consumer types are compatible', () => { expectTypeOf().toExtend() expectTypeOf().toEqualTypeOf< RawConsumer[ConsumerKeys] >() // Ensure that we can pass any Consumer as a RawConsumerUpdate expectTypeOf().toExtend() // Ensure that we can pass any RawConsumer as a RawConsumerUpdate expectTypeOf().toExtend() }) ================================================ FILE: apps/api/src/db/types.ts ================================================ import type { BuildQueryResult, ExtractTablesWithRelations, InferInsertModel, InferSelectModel } from '@fisch0920/drizzle-orm' import type { z } from '@hono/zod-openapi' import type { Simplify } from 'type-fest' import type * as schema from './schema' export type Tables = ExtractTablesWithRelations export type User = z.infer export type RawUser = InferSelectModel export type Team = z.infer export type TeamWithMembers = BuildQueryResult< Tables, Tables['teams'], { with: { members: true } } > export type RawTeam = InferSelectModel export type TeamMember = z.infer export type TeamMemberWithTeam = BuildQueryResult< Tables, Tables['teamMembers'], { with: { team: true } } > export type RawTeamMember = InferSelectModel export type Project = z.infer export type ProjectWithLastPublishedDeployment = BuildQueryResult< Tables, Tables['projects'], { with: { lastPublishedDeployment: true } } > export type RawProject = Simplify< InferSelectModel & { lastPublishedDeployment?: RawDeployment | null // TODO: remove null (requires drizzle-orm changes) lastDeployment?: RawDeployment | null // TODO: remove null (requires drizzle-orm changes) } > export type Deployment = z.infer export type DeploymentWithProject = BuildQueryResult< Tables, Tables['deployments'], { with: { project: true } } > export type RawDeployment = Simplify< InferSelectModel & { project?: RawProject | null // TODO: remove null (requires drizzle-orm changes) } > export type Consumer = z.infer export type ConsumerWithProjectAndDeployment = BuildQueryResult< Tables, Tables['consumers'], { with: { project: true; deployment: true } } > export type RawConsumer = Simplify< InferSelectModel & { user?: RawUser | undefined | null // TODO: remove null (requires drizzle-orm changes) project?: RawProject | undefined | null // TODO: remove null (requires drizzle-orm changes) deployment?: RawDeployment | undefined | null // TODO: remove null (requires drizzle-orm changes) } > export type RawConsumerUpdate = Partial< Omit< InferInsertModel, 'id' | 'projectId' | 'userId' | 'deploymentId' > > export type LogEntry = z.infer export type RawLogEntry = InferSelectModel export type Account = z.infer export type RawAccount = InferSelectModel ================================================ FILE: apps/api/src/db/utils.ts ================================================ import type { PricingPlan, PricingPlanLineItem } from '@agentic/platform-types' import { hashObject } from '@agentic/platform-core' import type { RawProject } from './types' /** * Gets the hash used to uniquely map a PricingPlanLineItem to its * corresponding Stripe Price in a stable way across deployments within a * project. * * This hash is used as the key for the `Project._stripePriceIdMap`. */ export async function getPricingPlanLineItemHashForStripePrice({ pricingPlan, pricingPlanLineItem, project }: { pricingPlan: PricingPlan pricingPlanLineItem: PricingPlanLineItem project: RawProject }): Promise { // TODO: use pricingPlan.slug as well here? // TODO: not sure if this is needed or not... // With pricing plan slug: // - 'price:free:base:' // - 'price:basic-monthly:base:' // - 'price:basic-monthly:requests:' // Without pricing plan slug: // - 'price:base:' // - 'price:base:' // - 'price:requests:' const hash = await hashObject({ ...pricingPlanLineItem, projectId: project.id, stripeAccountId: project._stripeAccountId, currency: project.pricingCurrency }) return `price:${pricingPlan.slug}:${pricingPlanLineItem.slug}:${hash}` } export async function getStripePriceIdForPricingPlanLineItem({ pricingPlan, pricingPlanLineItem, project }: { pricingPlan: PricingPlan pricingPlanLineItem: PricingPlanLineItem project: RawProject }): Promise { const pricingPlanLineItemHash = await getPricingPlanLineItemHashForStripePrice({ pricingPlan, pricingPlanLineItem, project }) return project._stripePriceIdMap[pricingPlanLineItemHash] } ================================================ FILE: apps/api/src/lib/__snapshots__/storage.test.ts.snap ================================================ // Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html exports[`Storage > uploadFileUrlToStorage data-uri 1`] = `"https://storage.agentic.so/@dev/test/ef4238fba78887e0974cd48809a66e284cdd78ce92d6b2d485c25a552fb39631.svg"`; exports[`Storage > uploadFileUrlToStorage data-uri 2 1`] = `"https://storage.agentic.so/@dev/test/efbc1f0409b730d93b01c918be9e024bf7777801cfd252221c39e36c08c1b4fb"`; exports[`Storage > uploadFileUrlToStorage url 1`] = `"https://storage.agentic.so/@dev/test/6da6ef895be2a42606b99e5e3b9c25687c92c81986a9718e07f32e574d41cf7a.svg"`; ================================================ FILE: apps/api/src/lib/acl-admin.ts ================================================ import { assert } from '@agentic/platform-core' import type { AuthenticatedHonoContext } from './types' import { ensureAuthUser } from './ensure-auth-user' export async function aclAdmin(ctx: AuthenticatedHonoContext) { const user = await ensureAuthUser(ctx) assert(user, 401, 'Authentication required') assert(user.role === 'admin', 403, 'Access denied') } ================================================ FILE: apps/api/src/lib/acl-public-project.ts ================================================ import { assert } from '@agentic/platform-core' import type { RawProject } from '@/db' export function aclPublicProject( project: RawProject | undefined, projectId?: string ): asserts project { assert( project, 404, `Public project not found${projectId ? ` "${projectId}"` : ''}` ) assert( !project.private && project.lastPublishedDeploymentId, 404, `Public project not found "${project.id}"` ) assert(!project.deletedAt, 410, `Project has been deleted "${project.id}"`) } ================================================ FILE: apps/api/src/lib/acl-team-admin.ts ================================================ import { assert } from '@agentic/platform-core' import { and, db, eq, schema, type TeamMember } from '@/db' import type { AuthenticatedHonoContext } from './types' import { ensureAuthUser } from './ensure-auth-user' export async function aclTeamAdmin( ctx: AuthenticatedHonoContext, { teamId, teamSlug, teamMember }: { teamId?: string teamSlug?: string teamMember?: TeamMember } & ( | { teamId: string teamSlug?: never } | { teamId?: never teamSlug: string } ) ) { const teamLabel = teamId ?? teamSlug assert(teamLabel, 500, 'Either teamSlug or teamId must be provided') const user = await ensureAuthUser(ctx) if (user.role === 'admin') { // TODO: Allow admins to access all team resources return } if (!teamMember) { teamMember = await db.query.teamMembers.findFirst({ where: and( teamId ? eq(schema.teamMembers.teamId, teamId) : eq(schema.teamMembers.teamSlug, teamSlug!), eq(schema.teamMembers.userId, user.id) ) }) } assert(teamMember, 403, `User does not have access to team "${teamLabel}"`) assert( teamMember.role === 'admin', 403, `User does not have "admin" role for team "${teamLabel}"` ) assert( teamMember.userId === user.id, 403, `User does not have access to team "${teamLabel}"` ) assert( teamMember.confirmed, 403, `User has not confirmed their invitation to team "${teamLabel}"` ) } ================================================ FILE: apps/api/src/lib/acl-team-member.ts ================================================ import { assert } from '@agentic/platform-core' import { and, db, eq, type RawTeamMember, schema } from '@/db' import type { AuthenticatedHonoContext } from './types' import { ensureAuthUser } from './ensure-auth-user' export async function aclTeamMember( ctx: AuthenticatedHonoContext, { teamId, teamSlug, teamMember, userId }: { teamId?: string teamSlug?: string teamMember?: RawTeamMember userId?: string } & ( | { teamSlug: string } | { teamId: string } | { teamMember: RawTeamMember } ) ) { const teamLabel = teamId ?? teamSlug assert(teamLabel, 500, 'Either teamSlug or teamId must be provided') const user = await ensureAuthUser(ctx) if (user.role === 'admin') { // TODO: Allow admins to access all team resources return } userId ??= user.id if (!teamMember) { teamMember = await db.query.teamMembers.findFirst({ where: and( teamId ? eq(schema.teamMembers.teamId, teamId) : eq(schema.teamMembers.teamSlug, teamSlug!), eq(schema.teamMembers.userId, userId) ) }) } assert(teamMember, 403, `User does not have access to team "${teamLabel}"`) if (!ctx.get('teamMember')) { ctx.set('teamMember', teamMember) } assert( teamMember.userId === userId, 403, `User does not have access to team "${teamLabel}"` ) assert( teamMember.confirmed, 403, `User has not confirmed their invitation to team "${teamLabel}"` ) } ================================================ FILE: apps/api/src/lib/acl.ts ================================================ import { assert } from '@agentic/platform-core' import type { AuthenticatedHonoContext } from './types' import { ensureAuthUser } from './ensure-auth-user' export async function acl< TModel extends Record, TUserField extends keyof TModel = 'userId', TTeamField extends keyof TModel = 'teamId' >( ctx: AuthenticatedHonoContext, model: TModel, { label, userField = 'userId' as TUserField, teamField = 'teamId' as TTeamField }: { label: string userField?: TUserField teamField?: TTeamField } ) { const user = await ensureAuthUser(ctx) const teamMember = ctx.get('teamMember') const userFieldValue = model[userField] const teamFieldValue = model[teamField] const isAuthUserOwner = userFieldValue && userFieldValue === user.id const isAuthUserAdmin = user.role === 'admin' const hasTeamAccess = teamMember && teamFieldValue && teamFieldValue === teamMember.teamId assert( isAuthUserOwner || isAuthUserAdmin || hasTeamAccess, 403, `User does not have access to ${label} "${model.id ?? userFieldValue}"` ) } ================================================ FILE: apps/api/src/lib/auth/auth-storage.ts ================================================ export interface AuthStorageAdapter { get(key: string[]): Promise | undefined> remove(key: string[]): Promise set(key: string[], value: any, expiry?: Date): Promise scan(prefix: string[]): AsyncIterable<[string[], any]> } const SEPERATOR = String.fromCodePoint(0x1f) export function joinKey(key: string[]) { return key.join(SEPERATOR) } export function splitKey(key: string) { return key.split(SEPERATOR) } export namespace AuthStorage { function encode(key: string[]) { return key.map((k) => k.replaceAll(SEPERATOR, '')) } export function get(adapter: AuthStorageAdapter, key: string[]) { return adapter.get(encode(key)) as Promise } export function set( adapter: AuthStorageAdapter, key: string[], value: any, ttl?: number ) { const expiry = ttl ? new Date(Date.now() + ttl * 1000) : undefined return adapter.set(encode(key), value, expiry) } export function remove(adapter: AuthStorageAdapter, key: string[]) { return adapter.remove(encode(key)) } export function scan( adapter: AuthStorageAdapter, key: string[] ): AsyncIterable<[string[], T]> { return adapter.scan(encode(key)) } } ================================================ FILE: apps/api/src/lib/auth/create-auth-token.ts ================================================ import { sign } from 'hono/jwt' import type { RawUser } from '@/db' import { env } from '@/lib/env' export async function createAuthToken(user: RawUser): Promise { return sign( { type: 'user', id: user.id, username: user.username }, env.JWT_SECRET ) } ================================================ FILE: apps/api/src/lib/auth/drizzle-auth-storage.ts ================================================ import { and, db, eq, gt, isNull, like, or, schema } from '@/db' import { type AuthStorageAdapter, joinKey, splitKey } from './auth-storage' export function DrizzleAuthStorage(): AuthStorageAdapter { return { async get(key: string[]) { const id = joinKey(key) const entry = await db.query.authData.findFirst({ where: eq(schema.authData.id, id) }) if (!entry) return undefined if (entry.expiry && Date.now() >= entry.expiry.getTime()) { await db.delete(schema.authData).where(eq(schema.authData.id, id)) return undefined } return entry.value }, async set(key: string[], value: Record, expiry?: Date) { const id = joinKey(key) await db .insert(schema.authData) .values({ id, value, expiry }) .onConflictDoUpdate({ target: schema.authData.id, set: { value, expiry: expiry ?? null } }) }, async remove(key: string[]) { const id = joinKey(key) await db.delete(schema.authData).where(eq(schema.authData.id, id)) }, async *scan(prefix: string[]) { const now = new Date() const idPrefix = joinKey(prefix) const entries = await db.query.authData.findMany({ where: and( like(schema.authData.id, `${idPrefix}%`), or(isNull(schema.authData.expiry), gt(schema.authData.expiry, now)) ) }) for (const entry of entries) { yield [splitKey(entry.id), entry.value] } } } } ================================================ FILE: apps/api/src/lib/auth/upsert-or-link-user-account.ts ================================================ import type { SetRequired, Simplify } from 'type-fest' import { assert } from '@agentic/platform-core' import { and, db, eq, type RawAccount, type RawUser, schema } from '@/db' import { createAvatar } from '../create-avatar' import { getUniqueNamespace } from '../ensure-unique-namespace' import { uploadFileUrlToStorage } from '../storage' /** * After a user completes an authentication flow, we'll have partial account info * and partial suer info. This function takes these partial values and maps them * to a valid database Account and User. * * This will result in the Account being upserted, and may result in a new User * being created. */ export async function upsertOrLinkUserAccount({ partialAccount, partialUser }: { partialAccount: Simplify< SetRequired< Partial< Pick< RawAccount, | 'provider' | 'accountId' | 'accountUsername' | 'accessToken' | 'refreshToken' | 'accessTokenExpiresAt' | 'refreshTokenExpiresAt' | 'scope' | 'password' > >, 'provider' | 'accountId' > > partialUser: Simplify< SetRequired< Partial< Pick< RawUser, 'email' | 'name' | 'username' | 'image' | 'isEmailVerified' > >, 'email' > > }): Promise { const { provider, accountId } = partialAccount const [existingAccount, existingUser] = await Promise.all([ db.query.accounts.findFirst({ where: and( eq(schema.accounts.provider, provider), eq(schema.accounts.accountId, accountId) ), with: { user: true } }), db.query.users.findFirst({ where: eq(schema.users.email, partialUser.email) }) ]) async function resolveUserProfileImage({ prefix }: { prefix: string }) { // Set a default profile image if one isn't provided partialUser.image = await uploadFileUrlToStorage( partialUser.image ?? createAvatar(partialUser.email), { prefix } ) } if (existingAccount && existingUser) { // Happy path case: the user is just logging in with an existing account // that's already linked to a user. assert( existingAccount.userId === existingUser.id, `Error authenticating with ${provider}: Account id "${existingAccount.id}" user id "${existingAccount.userId}" does not match expected user id "${existingUser.id}"` ) assert(provider !== 'password', 500) // Update the account with the up-to-date provider data, including any OAuth // tokens. await db .update(schema.accounts) .set(partialAccount) .where(eq(schema.accounts.id, existingAccount.id)) return existingUser } else if (existingUser && !existingAccount) { // Linking a new account to an existing user await db.insert(schema.accounts).values({ ...partialAccount, userId: existingUser.id }) // TODO: Same caveat as below: if the existing user has a different email than // the one in the account we're linking, we should throw an error unless it's // a "trusted" provider. if (provider === 'password' && existingUser.email !== partialUser.email) { await resolveUserProfileImage({ prefix: existingUser.username }) const [user] = await db .update(schema.users) .set(partialUser) .where(eq(schema.users.id, existingUser.id)) .returning() assert( user, 500, `Error updating existing user during ${provider} authentication` ) return user } return existingUser } else if (existingAccount && !existingUser) { assert( existingAccount.user, 404, `Error authenticating with ${provider}: Account id "${existingAccount.id}" is linked to a user with a different email address than their ${provider} account, but the linked account user id "${existingAccount.userId}" is not found.` ) // Existing account is linked to a user with a different email address than // this provider account. This should be fine since it's pretty common for // users to have multiple email addresses, but we may want to limit the // ability to automatically link accounts like this in the future to only // certain, trusted providers like `better-auth` does. return existingAccount.user } else { const username = await getUniqueNamespace( partialUser.username || partialUser.email.split('@')[0]!.toLowerCase(), { label: 'Username' } ) await resolveUserProfileImage({ prefix: username }) // This is a user's first time signing up with the platform, so create both // a new user and linked account. return db.transaction(async (tx) => { // Create a new user const [user] = await tx .insert(schema.users) .values({ ...partialUser, username }) .returning() assert( user, 500, `Error creating new user during ${provider} authentication` ) // Create a new account linked to the new user await tx.insert(schema.accounts).values({ ...partialAccount, userId: user.id }) return user }) } } ================================================ FILE: apps/api/src/lib/billing/create-stripe-checkout-session.ts ================================================ import type Stripe from 'stripe' import { assert } from '@agentic/platform-core' import type { AuthenticatedHonoContext } from '@/lib/types' import { getStripePriceIdForPricingPlanLineItem, type RawConsumer, type RawDeployment, type RawProject, type RawUser } from '@/db' import { stripe } from '@/lib/external/stripe' import { env } from '../env' export async function createStripeCheckoutSession( ctx: AuthenticatedHonoContext, { consumer, user, deployment, project, plan }: { consumer: RawConsumer user: RawUser deployment: RawDeployment project: RawProject plan?: string } ): Promise<{ id: string; url: string }> { const logger = ctx.get('logger') const stripeConnectParams = project._stripeAccountId ? [ { stripeAccount: project._stripeAccountId } ] : [] const stripeCustomerId = consumer._stripeCustomerId || user.stripeCustomerId assert( stripeCustomerId, 500, `Missing valid stripe customer. Please contact support for deployment "${deployment.id}" and consumer "${consumer.id}"` ) const pricingPlan = plan ? deployment.pricingPlans.find((pricingPlan) => pricingPlan.slug === plan) : undefined const action: 'create' | 'update' | 'cancel' = consumer._stripeSubscriptionId ? plan ? 'update' : 'cancel' : 'create' // TODO: test cancel => resubscribe flow if (consumer._stripeSubscriptionId) { // customer has an existing subscription const existingStripeSubscription = await stripe.subscriptions.retrieve( consumer._stripeSubscriptionId, ...stripeConnectParams ) const existingStripeSubscriptionItems = existingStripeSubscription.items.data logger.debug() logger.debug( 'existing stripe subscription', JSON.stringify(existingStripeSubscription, null, 2) ) logger.debug() assert( existingStripeSubscription.metadata?.userId === consumer.userId, 500, `Error updating stripe subscription: invalid existing subscription "${existingStripeSubscription.id}" metadata.userId for consumer "${consumer.id}"` ) assert( existingStripeSubscription.metadata?.consumerId === consumer.id, 500, `Error updating stripe subscription: invalid existing subscription "${existingStripeSubscription.id}" metadata.consumerId for consumer "${consumer.id}"` ) assert( existingStripeSubscription.metadata?.projectId === project.id, 500, `Error updating stripe subscription: invalid existing subscription "${existingStripeSubscription.id}" metadata.projectId for consumer "${consumer.id}"` ) if (!plan) { const billingPortalSession = await stripe.billingPortal.sessions.create( { customer: stripeCustomerId, return_url: `${env.AGENTIC_WEB_BASE_URL}/app/consumers`, flow_data: { type: 'subscription_cancel', subscription_cancel: { subscription: consumer._stripeSubscriptionId }, after_completion: { type: 'redirect', redirect: { return_url: `${env.AGENTIC_WEB_BASE_URL}/app/consumers/${consumer.id}?checkout=canceled` } } } }, ...stripeConnectParams ) return { id: billingPortalSession.id, url: billingPortalSession.url } } assert( pricingPlan, 404, `Unable to update stripe subscription for invalid pricing plan "${plan}"` ) const updateParams: Stripe.SubscriptionUpdateParams = { collection_method: 'charge_automatically', description: pricingPlan.description ?? `Subscription to ${project.name} ${pricingPlan.name}`, metadata: { plan: plan ?? null, consumerId: consumer.id, userId: consumer.userId, projectId: project.id, deploymentId: deployment.id } } const items: Stripe.SubscriptionUpdateParams.Item[] = await Promise.all( pricingPlan.lineItems.map(async (lineItem) => { const priceId = await getStripePriceIdForPricingPlanLineItem({ pricingPlan, pricingPlanLineItem: lineItem, project }) assert( priceId, 500, `Error updating stripe subscription: missing expected Stripe Price for plan "${pricingPlan.slug}" line-item "${lineItem.slug}"` ) // An existing Stripe Subscription Item may or may not exist for this // LineItem. It should exist if this is an update to an existing // LineItem. It won't exist if it's a new LineItem. const id = consumer._stripeSubscriptionItemIdMap[lineItem.slug] return { price: priceId, id, metadata: { lineItemSlug: lineItem.slug } } }) ) // Sanity check that LineItems we think should exist are all present in // the current subscription's items. for (const item of items) { if (item.id) { const existingItem = existingStripeSubscriptionItems.find( (existingItem) => item.id === existingItem.id ) assert( existingItem, 500, `Error updating stripe subscription: invalid pricing plan "${plan}" missing existing Subscription Item for "${item.id}"` ) } } for (const existingItem of existingStripeSubscriptionItems) { const updatedItem = items.find((item) => item.id === existingItem.id) if (!updatedItem) { const deletedItem: Stripe.SubscriptionUpdateParams.Item = { id: existingItem.id, deleted: true } items.push(deletedItem) } } assert( items.length || !plan, 500, `Error updating stripe subscription "${consumer._stripeSubscriptionId}"` ) for (const item of items) { if (!item.id) { delete item.id } } updateParams.items = items if (pricingPlan.trialPeriodDays) { const trialEnd = Math.trunc(Date.now() / 1000) + 24 * 60 * 60 * pricingPlan.trialPeriodDays // Reuse the existing trial end date if one exists. Otherwise, set a new // one for the updated subscription. updateParams.trial_end = existingStripeSubscription.trial_end ?? trialEnd } else if (existingStripeSubscription.trial_end) { // If the existing subscription has a trial end date, but the updated // subscription doesn't, we should end the trial now. updateParams.trial_end = 'now' } logger.info('>>> subscription', action, { items }) // TODO: Stripe Connect // if (project.isStripeConnectEnabled && project.applicationFeePercent > 0) { // updateParams.application_fee_percent = project.applicationFeePercent // } const subscription = await stripe.subscriptions.update( consumer._stripeSubscriptionId, updateParams, ...stripeConnectParams ) logger.info('<<< subscription', action, subscription) const billingPortalSession = await stripe.billingPortal.sessions.create( { customer: stripeCustomerId, return_url: `${env.AGENTIC_WEB_BASE_URL}/app/consumers` }, ...stripeConnectParams ) return { id: billingPortalSession.id, url: billingPortalSession.url } } else { // Creating a new subscription for this consumer for the first time. assert( pricingPlan, 404, `Unable to update stripe subscription for invalid pricing plan "${plan}"` ) const items: Stripe.Checkout.SessionCreateParams.LineItem[] = await Promise.all( pricingPlan.lineItems.map(async (lineItem) => { // An existing Stripe Subscription Item may or may not exist for this // LineItem. It should exist if this is an update to an existing // LineItem. It won't exist if it's a new LineItem. const id = consumer._stripeSubscriptionItemIdMap[lineItem.slug] assert( !id, 500, `Error creating stripe subscription: consumer contains a Stripe Subscription Item for LineItem "${lineItem.slug}" and pricing plan "${pricingPlan.slug}"` ) const priceId = await getStripePriceIdForPricingPlanLineItem({ pricingPlan, pricingPlanLineItem: lineItem, project }) assert( priceId, 500, `Error creating stripe subscription: missing expected Stripe Price for plan "${pricingPlan.slug}" line item "${lineItem.slug}"` ) return { price: priceId, // TODO: Make this customizable quantity: lineItem.usageType === 'licensed' ? 1 : undefined // metadata: { // lineItemSlug: lineItem.slug // } } satisfies Stripe.Checkout.SessionCreateParams.LineItem }) ) assert( items.length, 500, `Error creating stripe subscription: invalid plan "${plan}"` ) const checkoutSessionParams: Stripe.Checkout.SessionCreateParams = { customer: stripeCustomerId, mode: 'subscription', line_items: items, success_url: `${env.AGENTIC_WEB_BASE_URL}/app/consumers/${consumer.id}?checkout=success&plan=${plan}`, cancel_url: `${env.AGENTIC_WEB_BASE_URL}/marketplace/projects/${project.identifier}?checkout=canceled`, submit_type: 'subscribe', saved_payment_method_options: { payment_method_save: 'enabled' }, subscription_data: { description: pricingPlan.description ?? `Subscription to ${project.name} ${pricingPlan.name}`, trial_period_days: pricingPlan.trialPeriodDays, metadata: { plan: plan ?? null, consumerId: consumer.id, userId: consumer.userId, projectId: project.id, deploymentId: deployment.id } // TODO: Stripe Connect // application_fee_percent: project.applicationFeePercent }, // TODO: coupons // coupon: filterConsumerCoupon(ctx, consumer, deployment), // TODO: discounts // collection_method: 'charge_automatically', // TODO: consider custom_fields // TODO: consider custom_text // TODO: consider optional_items metadata: { plan: plan ?? null, consumerId: consumer.id, userId: consumer.userId, projectId: project.id, deploymentId: deployment.id } } // TODO: Stripe Connect // if (project.isStripeConnectEnabled && project.applicationFeePercent > 0) { // createParams.application_fee_percent = project.applicationFeePercent // } logger.debug('checkout session line_items', items) const checkoutSession = await stripe.checkout.sessions.create( checkoutSessionParams, ...stripeConnectParams ) assert(checkoutSession.url, 500, 'Missing stripe checkout session URL') return { id: checkoutSession.id, url: checkoutSession.url } } } ================================================ FILE: apps/api/src/lib/billing/upsert-stripe-connect-customer.ts ================================================ import type Stripe from 'stripe' import { assert } from '@agentic/platform-core' import { db, eq, type RawConsumer, type RawProject, schema } from '@/db' import { stripe } from '@/lib/external/stripe' // TODO: Update this for the new / updated Stripe Connect API export async function upsertStripeConnectCustomer({ stripeCustomer, consumer, project }: { stripeCustomer: Stripe.Customer consumer: RawConsumer project: RawProject }): Promise { if (!project._stripeAccountId) { return stripeCustomer } const stripeConnectParams = project._stripeAccountId ? [ { stripeAccount: project._stripeAccountId } ] : [] const stripeConnectCustomer = consumer._stripeCustomerId ? await stripe.customers.retrieve( consumer._stripeCustomerId, ...stripeConnectParams ) : await stripe.customers.create( { email: stripeCustomer.email!, metadata: stripeCustomer.metadata }, ...stripeConnectParams ) assert( stripeConnectCustomer, 500, `Failed to create stripe connect customer for user "${consumer.userId}"` ) assert( !stripeConnectCustomer.deleted, 500, `Stripe connect customer "${stripeConnectCustomer.id}" has been deleted` ) if (consumer._stripeCustomerId !== stripeConnectCustomer.id) { consumer._stripeCustomerId = stripeConnectCustomer.id await db .update(schema.consumers) .set({ _stripeCustomerId: stripeConnectCustomer.id }) .where(eq(schema.consumers.id, consumer.id)) } // TODO: Ensure stripe connect default "source" exists and is cloned from // platform stripe account. return stripeConnectCustomer } ================================================ FILE: apps/api/src/lib/billing/upsert-stripe-customer.ts ================================================ import type Stripe from 'stripe' import { assert } from '@agentic/platform-core' import type { AuthenticatedHonoContext } from '@/lib/types' import { db, eq, type RawUser, schema } from '@/db' import { ensureAuthUser } from '@/lib/ensure-auth-user' import { stripe } from '@/lib/external/stripe' export async function upsertStripeCustomer( ctx: AuthenticatedHonoContext ): Promise<{ user: RawUser stripeCustomer: Stripe.Customer }> { const user = await ensureAuthUser(ctx) if (user.stripeCustomerId) { const stripeCustomer = await stripe.customers.retrieve( user.stripeCustomerId ) assert( stripeCustomer, 404, `Stripe customer "${user.stripeCustomerId}" not found for user "${user.id}"` ) // TODO: handle this edge case assert( !stripeCustomer.deleted, 404, `Stripe customer "${user.stripeCustomerId}" is deleted for user "${user.id}"` ) return { user, stripeCustomer } } // TODO: add more metadata referencing signup LogEntry const metadata = { userId: user.id, email: user.email, username: user.username ?? null } const stripeCustomer = await stripe.customers.create({ email: user.email, metadata }) assert( stripeCustomer, 500, `Failed to create stripe customer for user "${user.id}"` ) user.stripeCustomerId = stripeCustomer.id await db .update(schema.users) .set({ stripeCustomerId: stripeCustomer.id }) .where(eq(schema.users.id, user.id)) return { user, stripeCustomer } } ================================================ FILE: apps/api/src/lib/billing/upsert-stripe-pricing-resources.ts ================================================ import type Stripe from 'stripe' import { assert } from '@agentic/platform-core' import { getLabelForPricingInterval, type PricingPlan, type PricingPlanLineItem } from '@agentic/platform-types' import pAll from 'p-all' import { db, eq, getPricingPlanLineItemHashForStripePrice, type RawDeployment, type RawProject, schema } from '@/db' import { stripe } from '@/lib/external/stripe' /** * Upserts all the Stripe resources corresponding to a Deployment's pricing * plans. * * This includes Stripe `Product`, `Meter`, and `Price` objects. * * All Stripe resource IDs are stored in the `_stripeProductIdMap`, * `_stripeMeterIdMap`, and `_stripePriceIdMap` fields of the given `project`. * * The `project` will be updated in the DB with any changes. * * The `deployment` is readonly and will not be updated, since all Stripe * resources persist on its Project so they can be reused if possible across * deployments. * * @note This function assumes that the deployment's pricing config has already * been validated. */ export async function upsertStripePricingResources({ deployment, project }: { deployment: Readonly project: RawProject }): Promise { assert( deployment.projectId === project.id, 'Deployment and project must match' ) // Keep track of promises for Stripe resources that are created in parallel // to avoid race conditions. const stripeProductIdPromiseMap = new Map>() const stripeMeterIdPromiseMap = new Map>() const stripePriceIdPromiseMap = new Map>() const stripeConnectParams = project._stripeAccountId ? [ { stripeAccount: project._stripeAccountId } ] : [] let dirty = false async function upsertStripeResourcesForPricingPlanLineItem({ pricingPlan, pricingPlanLineItem }: { pricingPlan: PricingPlan pricingPlanLineItem: PricingPlanLineItem }) { const { slug: pricingPlanSlug } = pricingPlan const { slug: pricingPlanLineItemSlug } = pricingPlanLineItem // Upsert the Stripe Product if (!project._stripeProductIdMap[pricingPlanLineItemSlug]) { if (stripeProductIdPromiseMap.has(pricingPlanLineItemSlug)) { const stripeProductId = await stripeProductIdPromiseMap.get( pricingPlanLineItemSlug )! project._stripeProductIdMap[pricingPlanLineItemSlug] = stripeProductId dirty = true } else { const productParams: Stripe.ProductCreateParams = { name: `${project.identifier} ${pricingPlanLineItemSlug}`, type: 'service', metadata: { projectId: project.id, pricingPlanLineItemSlug } } if (pricingPlanLineItem.usageType === 'licensed') { productParams.unit_label = pricingPlanLineItem.label } else { productParams.unit_label = pricingPlanLineItem.unitLabel } const productP = stripe.products.create( productParams, ...stripeConnectParams ) stripeProductIdPromiseMap.set( pricingPlanLineItemSlug, productP.then((p) => p.id) ) const product = await productP project._stripeProductIdMap[pricingPlanLineItemSlug] = product.id dirty = true } } assert(project._stripeProductIdMap[pricingPlanLineItemSlug]) if (pricingPlanLineItem.usageType === 'metered') { // Upsert the Stripe Meter if (!project._stripeMeterIdMap[pricingPlanLineItemSlug]) { if (stripeMeterIdPromiseMap.has(pricingPlanLineItemSlug)) { const stripeMeterId = await stripeMeterIdPromiseMap.get( pricingPlanLineItemSlug )! project._stripeMeterIdMap[pricingPlanLineItemSlug] = stripeMeterId dirty = true } else { const meterP = stripe.billing.meters.create( { display_name: `${project.identifier} ${pricingPlanLineItem.label || pricingPlanLineItemSlug}`, event_name: `meter-${project.id}-${pricingPlanLineItemSlug}`, // TODO: This currently isn't taken into account for the slug, so if it // changes across deployments, the meter will not be updated. default_aggregation: { formula: pricingPlanLineItem.defaultAggregation?.formula ?? 'sum' }, customer_mapping: { event_payload_key: 'stripe_customer_id', type: 'by_id' }, value_settings: { event_payload_key: 'value' } }, ...stripeConnectParams ) stripeMeterIdPromiseMap.set( pricingPlanLineItemSlug, meterP.then((m) => m.id) ) const stripeMeter = await meterP project._stripeMeterIdMap[pricingPlanLineItemSlug] = stripeMeter.id dirty = true } } assert(project._stripeMeterIdMap[pricingPlanLineItemSlug]) } else { assert(pricingPlanLineItem.usageType === 'licensed', 400) assert( !project._stripeMeterIdMap[pricingPlanLineItemSlug], 400, `Invalid pricing plan metric "${pricingPlanLineItemSlug}" for pricing plan "${pricingPlanSlug}": licensed pricing plan metrics cannot replace a previous metered pricing plan metric. Use a different pricing plan metric slug for the new licensed plan.` ) } const pricingPlanLineItemHashForStripePrice = await getPricingPlanLineItemHashForStripePrice({ pricingPlan, pricingPlanLineItem, project }) // Upsert the Stripe Price if (!project._stripePriceIdMap[pricingPlanLineItemHashForStripePrice]) { if (stripePriceIdPromiseMap.has(pricingPlanLineItemHashForStripePrice)) { const stripePriceId = await stripePriceIdPromiseMap.get( pricingPlanLineItemHashForStripePrice )! project._stripePriceIdMap[pricingPlanLineItemHashForStripePrice] = stripePriceId dirty = true } else { const interval = pricingPlan.interval ?? project.defaultPricingInterval // (nickname is hidden from customers) const nickname = [ 'price', project.id, pricingPlanLineItemSlug, getLabelForPricingInterval(interval) ] .filter(Boolean) .join('-') const priceParams: Stripe.PriceCreateParams = { product: project._stripeProductIdMap[pricingPlanLineItemSlug], currency: project.pricingCurrency, nickname, recurring: { interval, // TODO: support this interval_count: 1, usage_type: pricingPlanLineItem.usageType, meter: project._stripeMeterIdMap[pricingPlanLineItemSlug] }, metadata: { projectId: project.id, pricingPlanLineItemSlug } } if (pricingPlanLineItem.usageType === 'licensed') { priceParams.unit_amount_decimal = pricingPlanLineItem.amount.toFixed(12) } else { priceParams.billing_scheme = pricingPlanLineItem.billingScheme if (pricingPlanLineItem.billingScheme === 'tiered') { assert( pricingPlanLineItem.tiers?.length, 400, `Invalid pricing plan metric "${pricingPlanLineItemSlug}" for pricing plan "${pricingPlanSlug}": tiered billing schemes must have at least one tier.` ) priceParams.tiers_mode = pricingPlanLineItem.tiersMode priceParams.tiers = pricingPlanLineItem.tiers.map((tierData) => { const tier: Stripe.PriceCreateParams.Tier = { up_to: tierData.upTo } if (tierData.unitAmount !== undefined) { tier.unit_amount_decimal = tierData.unitAmount.toFixed(12) } if (tierData.flatAmount !== undefined) { tier.flat_amount_decimal = tierData.flatAmount.toFixed(12) } return tier }) } else { assert( pricingPlanLineItem.billingScheme === 'per_unit', 400, `Invalid pricing plan metric "${pricingPlanLineItemSlug}" for pricing plan "${pricingPlanSlug}": invalid billing scheme.` ) assert( pricingPlanLineItem.unitAmount !== undefined, 400, `Invalid pricing plan metric "${pricingPlanLineItemSlug}" for pricing plan "${pricingPlanSlug}": unitAmount is required for per_unit billing schemes.` ) priceParams.unit_amount_decimal = pricingPlanLineItem.unitAmount.toFixed(12) if (pricingPlanLineItem.transformQuantity) { priceParams.transform_quantity = { divide_by: pricingPlanLineItem.transformQuantity.divideBy, round: pricingPlanLineItem.transformQuantity.round } } } } const stripePriceP = stripe.prices.create( priceParams, ...stripeConnectParams ) stripePriceIdPromiseMap.set( pricingPlanLineItemHashForStripePrice, stripePriceP.then((p) => p.id) ) const stripePrice = await stripePriceP project._stripePriceIdMap[pricingPlanLineItemHashForStripePrice] = stripePrice.id dirty = true } } assert(project._stripePriceIdMap[pricingPlanLineItemHashForStripePrice]) } const upserts: Array<() => Promise> = [] for (const pricingPlan of deployment.pricingPlans) { for (const pricingPlanLineItem of pricingPlan.lineItems) { upserts.push(() => upsertStripeResourcesForPricingPlanLineItem({ pricingPlan, pricingPlanLineItem }) ) } } await pAll(upserts, { concurrency: 8 }) if (dirty) { await db .update(schema.projects) .set(project) .where(eq(schema.projects.id, project.id)) } } ================================================ FILE: apps/api/src/lib/billing/upsert-stripe-subscription.ts ================================================ import type Stripe from 'stripe' import { assert } from '@agentic/platform-core' import type { AuthenticatedHonoContext } from '@/lib/types' import { db, eq, getStripePriceIdForPricingPlanLineItem, type RawConsumer, type RawConsumerUpdate, type RawDeployment, type RawProject, type RawUser, schema } from '@/db' import { stripe } from '@/lib/external/stripe' import { setConsumerStripeSubscriptionStatus } from '../consumers/utils' export async function upsertStripeSubscription( ctx: AuthenticatedHonoContext, { consumer, user, deployment, project }: { consumer: RawConsumer user: RawUser deployment: RawDeployment project: RawProject } ): Promise<{ subscription: Stripe.Subscription consumer: RawConsumer }> { const logger = ctx.get('logger') const stripeConnectParams = project._stripeAccountId ? [ { stripeAccount: project._stripeAccountId } ] : [] const stripeCustomerId = consumer._stripeCustomerId || user.stripeCustomerId assert( stripeCustomerId, 500, `Missing valid stripe customer. Please contact support for deployment "${deployment.id}" and consumer "${consumer.id}"` ) const { plan } = consumer const pricingPlan = plan ? deployment.pricingPlans.find((pricingPlan) => pricingPlan.slug === plan) : undefined const action: 'create' | 'update' | 'cancel' = consumer._stripeSubscriptionId ? plan ? 'update' : 'cancel' : 'create' let subscription: Stripe.Subscription | undefined if (consumer._stripeSubscriptionId) { // customer has an existing subscription const existingStripeSubscription = await stripe.subscriptions.retrieve( consumer._stripeSubscriptionId, ...stripeConnectParams ) const existingStripeSubscriptionItems = existingStripeSubscription.items.data logger.debug() logger.debug( 'existing stripe subscription', JSON.stringify(existingStripeSubscription, null, 2) ) logger.debug() assert( existingStripeSubscription.metadata?.userId === consumer.userId, 500, `Error updating stripe subscription: invalid existing subscription "${existingStripeSubscription.id}" metadata.userId for consumer "${consumer.id}"` ) assert( existingStripeSubscription.metadata?.consumerId === consumer.id, 500, `Error updating stripe subscription: invalid existing subscription "${existingStripeSubscription.id}" metadata.consumerId for consumer "${consumer.id}"` ) assert( existingStripeSubscription.metadata?.projectId === project.id, 500, `Error updating stripe subscription: invalid existing subscription "${existingStripeSubscription.id}" metadata.projectId for consumer "${consumer.id}"` ) const updateParams: Stripe.SubscriptionUpdateParams = { collection_method: 'charge_automatically', metadata: { plan: plan ?? null, userId: consumer.userId, consumerId: consumer.id, projectId: project.id, deploymentId: deployment.id } } if (plan) { assert( pricingPlan, 404, `Unable to update stripe subscription for invalid pricing plan "${plan}"` ) const items: Stripe.SubscriptionUpdateParams.Item[] = await Promise.all( pricingPlan.lineItems.map(async (lineItem) => { const priceId = await getStripePriceIdForPricingPlanLineItem({ pricingPlan, pricingPlanLineItem: lineItem, project }) assert( priceId, 500, `Error updating stripe subscription: missing expected Stripe Price for plan "${pricingPlan.slug}" line-item "${lineItem.slug}"` ) // An existing Stripe Subscription Item may or may not exist for this // LineItem. It should exist if this is an update to an existing // LineItem. It won't exist if it's a new LineItem. const id = consumer._stripeSubscriptionItemIdMap[lineItem.slug] return { price: priceId, id, metadata: { lineItemSlug: lineItem.slug } } }) ) // Sanity check that LineItems we think should exist are all present in // the current subscription's items. for (const item of items) { if (item.id) { const existingItem = existingStripeSubscriptionItems.find( (existingItem) => item.id === existingItem.id ) assert( existingItem, 500, `Error updating stripe subscription: invalid pricing plan "${plan}" missing existing Subscription Item for "${item.id}"` ) } } for (const existingItem of existingStripeSubscriptionItems) { const updatedItem = items.find((item) => item.id === existingItem.id) if (!updatedItem) { const deletedItem: Stripe.SubscriptionUpdateParams.Item = { id: existingItem.id, deleted: true } items.push(deletedItem) } } assert( items.length || !plan, 500, `Error updating stripe subscription "${consumer._stripeSubscriptionId}"` ) for (const item of items) { if (!item.id) { delete item.id } } updateParams.items = items if (pricingPlan.trialPeriodDays) { const trialEnd = Math.trunc(Date.now() / 1000) + 24 * 60 * 60 * pricingPlan.trialPeriodDays // Reuse the existing trial end date if one exists. Otherwise, set a new // one for the updated subscription. updateParams.trial_end = existingStripeSubscription.trial_end ?? trialEnd } else if (existingStripeSubscription.trial_end) { // If the existing subscription has a trial end date, but the updated // subscription doesn't, we should end the trial now. updateParams.trial_end = 'now' } logger.debug('subscription', action, { items }) } else { updateParams.cancel_at_period_end = true } // TODO: Stripe Connect // if (project.isStripeConnectEnabled && project.applicationFeePercent > 0) { // updateParams.application_fee_percent = project.applicationFeePercent // } subscription = await stripe.subscriptions.update( consumer._stripeSubscriptionId, updateParams, ...stripeConnectParams ) // TODO: this will cancel the subscription without resolving current usage / invoices // await stripe.subscriptions.del(consumer.stripeSubscription) } else { // Creating a new subscription for this consumer for the first time. assert( pricingPlan, 404, `Unable to update stripe subscription for invalid pricing plan "${plan}"` ) const items: Stripe.SubscriptionCreateParams.Item[] = await Promise.all( pricingPlan.lineItems.map(async (lineItem) => { const priceId = await getStripePriceIdForPricingPlanLineItem({ pricingPlan, pricingPlanLineItem: lineItem, project }) assert( priceId, 500, `Error creating stripe subscription: missing expected Stripe Price for plan "${pricingPlan.slug}" line item "${lineItem.slug}"` ) // An existing Stripe Subscription Item may or may not exist for this // LineItem. It should exist if this is an update to an existing // LineItem. It won't exist if it's a new LineItem. const id = consumer._stripeSubscriptionItemIdMap[lineItem.slug] assert( !id, 500, `Error creating stripe subscription: consumer contains a Stripe Subscription Item for LineItem "${lineItem.slug}" and pricing plan "${pricingPlan.slug}"` ) return { price: priceId, metadata: { lineItemSlug: lineItem.slug } } }) ) assert( items.length, 500, `Error creating stripe subscription: invalid plan "${plan}"` ) const createParams: Stripe.SubscriptionCreateParams = { customer: stripeCustomerId, description: `Agentic subscription to project "${project.identifier}"`, // TODO: coupons // coupon: filterConsumerCoupon(ctx, consumer, deployment), items, // collection_method: 'charge_automatically', metadata: { plan: plan ?? null, consumerId: consumer.id, userId: consumer.userId, projectId: project.id, deploymentId: deployment.id } } if (pricingPlan.trialPeriodDays) { createParams.trial_period_days = pricingPlan.trialPeriodDays } // TODO: Stripe Connect // if (project.isStripeConnectEnabled && project.applicationFeePercent > 0) { // createParams.application_fee_percent = project.applicationFeePercent // } logger.debug('subscription', action, { items }) subscription = await stripe.subscriptions.create( createParams, ...stripeConnectParams ) consumer._stripeSubscriptionId = subscription.id } // ---------------------------------------------------- // Same codepath for updating, creating, and cancelling // ---------------------------------------------------- assert(subscription, 500, 'Missing stripe subscription') logger.debug('subscription', subscription) const consumerUpdate: RawConsumerUpdate = consumer consumerUpdate.stripeStatus = subscription.status setConsumerStripeSubscriptionStatus(consumerUpdate) // if (!plan) { // TODO: we cancel at the end of the billing interval, so we shouldn't // invalidate the stripe subscription just yet. That should happen via // webhook. And we should never set `_stripeSubscriptionId` to `null`. // consumerUpdate._stripeSubscriptionId = null // consumerUpdate.stripeStatus = 'cancelled' // } if (pricingPlan) { for (const lineItem of pricingPlan.lineItems) { const stripeSubscriptionItemId = consumer._stripeSubscriptionItemIdMap[lineItem.slug] const stripeSubscriptionItem: Stripe.SubscriptionItem | undefined = subscription.items.data.find((item) => stripeSubscriptionItemId ? item.id === stripeSubscriptionItemId : item.metadata?.lineItemSlug === lineItem.slug ) assert( stripeSubscriptionItem, 500, `Error post-processing stripe subscription for line-item "${lineItem.slug}" on plan "${pricingPlan.slug}"` ) consumerUpdate._stripeSubscriptionItemIdMap![lineItem.slug] = stripeSubscriptionItem.id assert( consumerUpdate._stripeSubscriptionItemIdMap![lineItem.slug], 500, `Error post-processing stripe subscription for line-item "${lineItem.slug}" on plan "${pricingPlan.slug}"` ) } } logger.debug() logger.debug('consumer update', consumerUpdate) const [updatedConsumer] = await db .update(schema.consumers) .set(consumerUpdate) .where(eq(schema.consumers.id, consumer.id)) .returning() assert(updatedConsumer, 500, 'Error updating consumer') // await auditLog.createStripeSubscriptionLogEntry(ctx, { // consumer, // user, // plan: consumer.plan, // subtype: action // }) return { subscription, consumer: updatedConsumer } } ================================================ FILE: apps/api/src/lib/cache-control.ts ================================================ import { assert } from '@agentic/platform-core' export type PublicCacheControlLevels = | '1s' | '10s' | '30s' | '1m' | '5m' | '10m' | '30m' | '1h' | '1d' const publicCacheControlLevelsMap: Record = { '1s': 'public, max-age=1, s-maxage=1 stale-while-revalidate=0', '10s': 'public, max-age=10, s-maxage=10 stale-while-revalidate=1', '30s': 'public, max-age=30, s-maxage=30 stale-while-revalidate=5', '1m': 'public, max-age=60, s-maxage=60 stale-while-revalidate=10', '5m': 'public, max-age=300, s-maxage=300 stale-while-revalidate=60', '10m': 'public, max-age=600, s-maxage=600 stale-while-revalidate=120', '30m': 'public, max-age=1800, s-maxage=1800 stale-while-revalidate=300', '1h': 'public, max-age=3600, s-maxage=3600, stale-while-revalidate=500', '1d': 'public, max-age=86400, s-maxage=86400, stale-while-revalidate=3600' } export function setPublicCacheControl( res: Response, level: PublicCacheControlLevels ) { const cacheControl = publicCacheControlLevelsMap[level] assert(cacheControl, `Invalid cache control level "${level}"`) res.headers.set('cache-control', cacheControl) } ================================================ FILE: apps/api/src/lib/consumers/upsert-consumer-stripe-checkout.ts ================================================ import { assert } from '@agentic/platform-core' import type { AuthenticatedHonoContext } from '@/lib/types' import { and, db, eq, type RawConsumer, type RawDeployment, type RawProject, schema } from '@/db' import { acl } from '@/lib/acl' import { upsertStripeConnectCustomer } from '@/lib/billing/upsert-stripe-connect-customer' import { upsertStripeCustomer } from '@/lib/billing/upsert-stripe-customer' import { upsertStripePricingResources } from '@/lib/billing/upsert-stripe-pricing-resources' import { createConsumerApiKey } from '@/lib/create-consumer-api-key' import { aclPublicProject } from '../acl-public-project' import { createStripeCheckoutSession } from '../billing/create-stripe-checkout-session' export async function upsertConsumerStripeCheckout( c: AuthenticatedHonoContext, { plan, deploymentId, consumerId }: { plan?: string deploymentId?: string consumerId?: string } ): Promise<{ checkoutSession: { id: string; url: string } consumer: RawConsumer }> { assert( consumerId || deploymentId, 400, 'Internal error: upsertConsumerStripeCheckout missing required "deploymentId" or "consumerId"' ) const logger = c.get('logger') const userId = c.get('userId') let deployment: RawDeployment | undefined let project: RawProject | undefined let projectId: string | undefined logger.info('upsertConsumerStripeCheckout', { plan, deploymentId, consumerId }) async function initDeploymentAndProject() { assert(deploymentId, 400, 'Missing required "deploymentId"') if (deployment && project) { // Already initialized return } deployment = await db.query.deployments.findFirst({ where: eq(schema.deployments.id, deploymentId), with: { project: true } }) assert(deployment, 404, `Deployment not found "${deploymentId}"`) project = deployment.project! assert( project, 404, `Project not found "${projectId}" for deployment "${deploymentId}"` ) aclPublicProject(project) // Validate the deployment only after we're sure the project is publicly // accessible. assert( !deployment.deletedAt, 410, `Deployment has been deleted by its owner "${deployment.id}"` ) projectId = project.id } if (deploymentId) { await initDeploymentAndProject() } if (!consumerId) { assert(projectId, 400, 'Missing required "projectId"') } const [{ user, stripeCustomer }, existingConsumer] = await Promise.all([ upsertStripeCustomer(c), db.query.consumers.findFirst({ where: consumerId ? eq(schema.consumers.id, consumerId) : and( eq(schema.consumers.userId, userId), eq(schema.consumers.projectId, projectId!) ) }) ]) if (consumerId) { assert(existingConsumer, 404, `Consumer not found "${consumerId}"`) assert(existingConsumer.id === consumerId, 403) await acl(c, existingConsumer, { label: 'Consumer' }) if (projectId) { assert( existingConsumer.projectId === projectId, 400, `Deployment "${deploymentId}" does not belong to project "${existingConsumer.projectId}" for consumer "${consumerId}"` ) } deploymentId ??= existingConsumer.deploymentId projectId ??= existingConsumer.projectId } assert(deploymentId) assert(projectId) assert( !existingConsumer || !existingConsumer.isStripeSubscriptionActive || existingConsumer.plan !== plan || existingConsumer.deploymentId !== deploymentId, 409, plan ? `User "${user.email}" already has an active subscription to plan "${plan}" for project "${projectId}"` : `User "${user.email}" already has cancelled their subscription for project "${projectId}"` ) await initDeploymentAndProject() assert(deployment, 500, `Error getting deployment "${deploymentId}"`) assert(project, 500, `Error getting project "${projectId}"`) if (plan) { const pricingPlan = deployment.pricingPlans.find((p) => p.slug === plan) assert( pricingPlan, 400, `Pricing plan "${plan}" not found for deployment "${deploymentId}"` ) } let consumer = existingConsumer if (consumer) { // Don't update the consumer until the checkout session is completed // successfully. // ;[consumer] = await db // .update(schema.consumers) // .set({ // plan, // deploymentId // }) // .where(eq(schema.consumers.id, consumer.id)) // .returning() } else { // Create a new consumer, but don't set the plan yet until the checkout // session is completed successfully. ;[consumer] = await db .insert(schema.consumers) .values({ // plan, userId, projectId, deploymentId, token: await createConsumerApiKey(), _stripeCustomerId: stripeCustomer.id }) .returning() } assert( consumer, 500, 'Internal error: upsertConsumerStripeCheckout error creating consumer' ) // Ensure that all Stripe pricing resources exist for this deployment await upsertStripePricingResources({ deployment, project }) // Ensure that customer and default source are created on the stripe connect account // TODO: is this necessary? // consumer._stripeAccount = project._stripeAccount // TODO: this function may mutate `consumer` await upsertStripeConnectCustomer({ stripeCustomer, consumer, project }) logger.info( 'CONSUMER STRIPE CHECKOUT', existingConsumer ? 'UPDATE' : 'CREATE', { project, deployment, consumer } ) const checkoutSession = await createStripeCheckoutSession(c, { consumer, user, project, deployment, plan }) logger.info('checkout session', checkoutSession) return { checkoutSession, consumer } } ================================================ FILE: apps/api/src/lib/consumers/upsert-consumer.ts ================================================ import { assert } from '@agentic/platform-core' import type { AuthenticatedHonoContext } from '@/lib/types' import { and, db, eq, type RawDeployment, type RawProject, schema } from '@/db' import { acl } from '@/lib/acl' import { upsertStripeConnectCustomer } from '@/lib/billing/upsert-stripe-connect-customer' import { upsertStripeCustomer } from '@/lib/billing/upsert-stripe-customer' import { upsertStripePricingResources } from '@/lib/billing/upsert-stripe-pricing-resources' import { upsertStripeSubscription } from '@/lib/billing/upsert-stripe-subscription' import { createConsumerApiKey } from '@/lib/create-consumer-api-key' import { aclPublicProject } from '../acl-public-project' export async function upsertConsumer( c: AuthenticatedHonoContext, { plan, deploymentId, consumerId }: { plan?: string deploymentId?: string consumerId?: string } ) { assert( consumerId || deploymentId, 400, 'Internal error: upsertConsumer missing required "deploymentId" or "consumerId"' ) const logger = c.get('logger') const userId = c.get('userId') let deployment: RawDeployment | undefined let project: RawProject | undefined let projectId: string | undefined async function initDeploymentAndProject() { assert(deploymentId, 400, 'Missing required "deploymentId"') if (deployment && project) { // Already initialized return } deployment = await db.query.deployments.findFirst({ where: eq(schema.deployments.id, deploymentId), with: { project: true } }) assert(deployment, 404, `Deployment not found "${deploymentId}"`) assert( !deployment.deletedAt, 410, `Deployment has been deleted by its owner "${deployment.id}"` ) project = deployment.project! assert( project, 404, `Project not found "${projectId}" for deployment "${deploymentId}"` ) aclPublicProject(project) // Validate the deployment only after we're sure the project is publicly // accessible. assert( !deployment.deletedAt, 410, `Deployment has been deleted by its owner "${deployment.id}"` ) projectId = project.id } if (deploymentId) { await initDeploymentAndProject() } if (!consumerId) { assert(projectId, 400, 'Missing required "deploymentId"') } const [{ user, stripeCustomer }, existingConsumer] = await Promise.all([ upsertStripeCustomer(c), db.query.consumers.findFirst({ where: consumerId ? eq(schema.consumers.id, consumerId) : and( eq(schema.consumers.userId, userId), eq(schema.consumers.projectId, projectId!) ) }) ]) if (consumerId) { assert(existingConsumer, 404, `Consumer not found "${consumerId}"`) assert(existingConsumer.id === consumerId, 403) await acl(c, existingConsumer, { label: 'Consumer' }) if (projectId) { assert( existingConsumer.projectId === projectId, 400, `Deployment "${deploymentId}" does not belong to project "${existingConsumer.projectId}" for consumer "${consumerId}"` ) } deploymentId ??= existingConsumer.deploymentId projectId ??= existingConsumer.projectId } else { assert( !existingConsumer, 409, `User "${user.email}" already has a subscription for project "${projectId ?? ''}"` ) } assert(deploymentId) assert(projectId) assert( !existingConsumer || !existingConsumer.isStripeSubscriptionActive || existingConsumer.plan !== plan || existingConsumer.deploymentId !== deploymentId, 409, plan ? `User "${user.email}" already has an active subscription to plan "${plan}" for project "${projectId}"` : `User "${user.email}" already has cancelled their subscription for project "${projectId}"` ) await initDeploymentAndProject() assert(deployment, 500, `Error getting deployment "${deploymentId}"`) assert(project, 500, `Error getting project "${projectId}"`) if (plan) { const pricingPlan = deployment.pricingPlans.find((p) => p.slug === plan) assert( pricingPlan, 400, `Pricing plan "${plan}" not found for deployment "${deploymentId}"` ) } let consumer = existingConsumer if (consumer) { ;[consumer] = await db .update(schema.consumers) .set({ plan, deploymentId }) .where(eq(schema.consumers.id, consumer.id)) .returning() } else { ;[consumer] = await db .insert(schema.consumers) .values({ plan, userId, projectId, deploymentId, token: await createConsumerApiKey(), _stripeCustomerId: stripeCustomer.id }) .returning() } assert(consumer, 500, 'Error creating consumer') // Ensure that all Stripe pricing resources exist for this deployment await upsertStripePricingResources({ deployment, project }) // Ensure that customer and default source are created on the stripe connect account // TODO: is this necessary? // consumer._stripeAccount = project._stripeAccount await upsertStripeConnectCustomer({ stripeCustomer, consumer, project }) logger.info('SUBSCRIPTION', existingConsumer ? 'UPDATE' : 'CREATE', { project, deployment, consumer }) const { subscription, consumer: updatedConsumer } = await upsertStripeSubscription(c, { consumer, user, project, deployment }) logger.info('subscription', subscription) return updatedConsumer } ================================================ FILE: apps/api/src/lib/consumers/utils.ts ================================================ import type { RawConsumerUpdate } from '@/db' // https://docs.stripe.com/api/subscriptions/object#subscription_object-status const stripeValidSubscriptionStatuses = new Set([ 'active', 'trialing', 'incomplete', 'past_due' ]) export function setConsumerStripeSubscriptionStatus( consumer: Pick< RawConsumerUpdate, 'plan' | 'stripeStatus' | 'isStripeSubscriptionActive' > ) { consumer.isStripeSubscriptionActive = consumer.plan === 'free' || (!!consumer.stripeStatus && stripeValidSubscriptionStatuses.has(consumer.stripeStatus)) } ================================================ FILE: apps/api/src/lib/create-avatar.ts ================================================ import { identicon } from '@dicebear/collection' import { createAvatar as createAvatarImpl } from '@dicebear/core' const defaultAgenticAvatar = 'data:image/svg+xml;base64,PHN2ZyB3aWR0aD0iODIiIGhlaWdodD0iODIiIHZpZXdCb3g9IjAgMCA4MiA4MiIgZmlsbD0ibm9uZSIgeG1sbnM9Imh0dHA6Ly93d3cudzMub3JnLzIwMDAvc3ZnIj4KPGNpcmNsZSBjeD0iNDEiIGN5PSI0MSIgcj0iNDEiIGZpbGw9ImJsYWNrIi8+CjxwYXRoIGZpbGwtcnVsZT0iZXZlbm9kZCIgY2xpcC1ydWxlPSJldmVub2RkIiBkPSJNMjIuNDU3OCA2MS41MzA4QzE5LjMyNTMgNjEuNTcyIDE2LjIxNDIgNjEuNTMwNSAxMy4xMjQ1IDYxLjQwNjNDMjIuODUwNiA0Ni4wMjAxIDMyLjU3OCAzMC42MzA2IDQyLjMwNjcgMTUuMjM3NEM0My43MDIxIDEzLjgwMjEgNDUuMjU3NyAxMy42MTU0IDQ2Ljk3MzQgMTQuNjc3NEM0OS4xMDY5IDE2LjQ3MjcgNDkuNzQ5OSAxOC42OTE5IDQ4LjkwMjMgMjEuMzM1MkM0Ny4zOTg4IDIzLjg4NjMgNDUuODY0IDI2LjQxNjcgNDQuMjk3OCAyOC45MjYzQzM3Ljc0MzcgMzkuMjk2NyAzMS4xODk3IDQ5LjY2NzEgMjQuNjM1NiA2MC4wMzc0QzI0LjE2MSA2MC45MDY4IDIzLjQzNTEgNjEuNDA0NiAyMi40NTc4IDYxLjUzMDhaIiBmaWxsPSJ3aGl0ZSIvPgo8cGF0aCBmaWxsLXJ1bGU9ImV2ZW5vZGQiIGNsaXAtcnVsZT0iZXZlbm9kZCIgZD0iTTQ3LjM0NjYgMzAuOTE3NUM0Ny42MjU0IDMxLjA1MDQgNDcuODUzNiAzMS4yNTc3IDQ4LjAzMSAzMS41Mzk3QzU0Ljk3NDcgNDEuNTkwMSA2MS45NjQzIDUxLjYwNzkgNjguOTk5OSA2MS41OTNDNjUuOTMwMyA2MS42NzYgNjIuODYwNyA2MS42NzYgNTkuNzkxIDYxLjU5M0M1OC45MTk4IDYxLjM5NiA1OC4xOTM5IDYwLjk2MDUgNTcuNjEzMyA2MC4yODY0QzUzLjI0NjkgNTMuOTE4IDQ4Ljg0OTkgNDcuNTcxNCA0NC40MjIyIDQxLjI0NjRDNDMuOTU3NyA0MC41NzM2IDQzLjU0MyAzOS44Njg0IDQzLjE3NzcgMzkuMTMwOEM0My4wMjUgMzguMTc3NiA0My4yMzI1IDM3LjMwNjUgNDMuNzk5OSAzNi41MTc1QzQ1LjAxMzQgMzQuNjYzMSA0Ni4xOTU2IDMyLjc5NjUgNDcuMzQ2NiAzMC45MTc1WiIgZmlsbD0id2hpdGUiLz4KPHBhdGggZmlsbC1ydWxlPSJldmVub2RkIiBjbGlwLXJ1bGU9ImV2ZW5vZGQiIGQ9Ik01NC41NjQ1IDYxLjQwNjNDNTEuMDE4NSA2MS41MzA1IDQ3LjQ1MSA2MS41NzE5IDQzLjg2MjMgNjEuNTMwN0M0Mi45MTQ5IDYwLjEwNTUgNDEuOTQwMSA1OC42OTUyIDQwLjkzNzggNTcuMjk5NkM0MC4wMTM2IDU4LjY5MjUgMzkuMTIxNyA2MC4xMDI5IDM4LjI2MjMgNjEuNTMwN0MzNC43NTY0IDYxLjU3MTkgMzEuMjcyIDYxLjUzMDUgMjcuODA5IDYxLjQwNjNDMzEuMDc4OSA1Ni4yNDgxIDM0LjM3NjYgNTEuMTA0NCAzNy43MDIzIDQ1Ljk3NTJDMzkuMDUwMyA0NC41NjI5IDQwLjY0NzQgNDQuMjEwMyA0Mi40OTM0IDQ0LjkxNzRDNDIuOTgzNyA0NS4xNTIxIDQzLjQxOTMgNDUuNDYzMiA0My44MDAxIDQ1Ljg1MDdDNDcuNDE1NyA1MS4wMjEgNTEuMDAzOCA1Ni4yMDYzIDU0LjU2NDUgNjEuNDA2M1oiIGZpbGw9IndoaXRlIi8+CjxwYXRoIGZpbGwtcnVsZT0iZXZlbm9kZCIgY2xpcC1ydWxlPSJldmVub2RkIiBkPSJNMTMuMTI0NCA2MS40MDYzQzE2LjIxNDIgNjEuNTMwNSAxOS4zMjUzIDYxLjU3MTkgMjIuNDU3OCA2MS41MzA4QzE5LjMyNTUgNjEuNjk2MSAxNi4xNzMgNjEuNjk2MSAxMyA2MS41MzA4QzEzLjAxNTQgNjEuNDU1MiAxMy4wNTY5IDYxLjQxMzggMTMuMTI0NCA2MS40MDYzWiIgZmlsbD0id2hpdGUiLz4KPHBhdGggZmlsbC1ydWxlPSJldmVub2RkIiBjbGlwLXJ1bGU9ImV2ZW5vZGQiIGQ9Ik0yNy44MDg5IDYxLjQwNjNDMzEuMjcxOSA2MS41MzA1IDM0Ljc1NjQgNjEuNTcxOSAzOC4yNjIyIDYxLjUzMDhDMzQuNzU2NiA2MS42OTYxIDMxLjIzMDcgNjEuNjk2MSAyNy42ODQ0IDYxLjUzMDhDMjcuNjk5OSA2MS40NTUyIDI3Ljc0MTMgNjEuNDEzOCAyNy44MDg5IDYxLjQwNjNaIiBmaWxsPSJ3aGl0ZSIvPgo8cGF0aCBmaWxsLXJ1bGU9ImV2ZW5vZGQiIGNsaXAtcnVsZT0iZXZlbm9kZCIgZD0iTTU0LjU2NDQgNjEuNDA2M0M1NC42MzIgNjEuNDEzOCA1NC42NzM0IDYxLjQ1NTIgNTQuNjg4OCA2MS41MzA4QzUxLjA1OTYgNjEuNjk2MyA0Ny40NTA3IDYxLjY5NjMgNDMuODYyMiA2MS41MzA4QzQ3LjQ1MDkgNjEuNTcxOSA1MS4wMTg0IDYxLjUzMDUgNTQuNTY0NCA2MS40MDYzWiIgZmlsbD0id2hpdGUiLz4KPC9zdmc+Cg==' const defaultDevAgenticAvatar = 'data:image/svg+xml;base64,PHN2ZyB3aWR0aD0iOTYiIGhlaWdodD0iOTYiIHZpZXdCb3g9IjAgMCA5NiA5NiIgZmlsbD0ibm9uZSIgeG1sbnM9Imh0dHA6Ly93d3cudzMub3JnLzIwMDAvc3ZnIj4KPGNpcmNsZSBjeD0iNDgiIGN5PSI0OCIgcj0iNDgiIGZpbGw9IiM4ODg4ODgiLz4KPHBhdGggZmlsbC1ydWxlPSJldmVub2RkIiBjbGlwLXJ1bGU9ImV2ZW5vZGQiIGQ9Ik0yNi4yOTIyIDczLjIwNjdDMjIuNjI0OCA3My4yNTUgMTguOTgyNSA3My4yMDY1IDE1LjM2NTMgNzMuMDYxMUMyNi43NTIgNTUuMDQ4IDM4LjE0MDIgMzcuMDMwOSA0OS41Mjk5IDE5LjAwOTdDNTEuMTYzNSAxNy4zMjkzIDUyLjk4NDcgMTcuMTEwNyA1NC45OTMzIDE4LjM1NDFDNTcuNDkxIDIwLjQ1NTggNTguMjQzOCAyMy4wNTM5IDU3LjI1MTUgMjYuMTQ4NUM1NS40OTEzIDI5LjEzNTIgNTMuNjk0NSAzMi4wOTc1IDUxLjg2MDkgMzUuMDM1N0M0NC4xODc4IDQ3LjE3NjYgMzYuNTE0OSA1OS4zMTc2IDI4Ljg0MTcgNzEuNDU4NUMyOC4yODYxIDcyLjQ3NjMgMjcuNDM2MyA3My4wNTkgMjYuMjkyMiA3My4yMDY3WiIgZmlsbD0id2hpdGUiLz4KPHBhdGggZmlsbC1ydWxlPSJldmVub2RkIiBjbGlwLXJ1bGU9ImV2ZW5vZGQiIGQ9Ik01NS40MzAyIDM3LjM2NjhDNTUuNzU2NiAzNy41MjI0IDU2LjAyMzcgMzcuNzY1MSA1Ni4yMzE1IDM4LjA5NTJDNjQuMzYwNiA0OS44NjE1IDcyLjU0MzcgNjEuNTg5NyA4MC43ODA0IDczLjI3OTZDNzcuMTg2NyA3My4zNzY4IDczLjU5MzEgNzMuMzc2OCA2OS45OTkzIDczLjI3OTZDNjguOTc5MyA3My4wNDkgNjguMTI5NSA3Mi41MzkxIDY3LjQ0OTcgNzEuNzQ5OUM2Mi4zMzc5IDY0LjI5NDMgNTcuMTkwMiA1Ni44NjQgNTIuMDA2NSA0OS40NTkxQzUxLjQ2MjcgNDguNjcxNSA1MC45NzcyIDQ3Ljg0NTkgNTAuNTQ5NiA0Ni45ODI0QzUwLjM3MDggNDUuODY2NCA1MC42MTM3IDQ0Ljg0NjYgNTEuMjc4IDQzLjkyMjlDNTIuNjk4NiA0MS43NTE5IDU0LjA4MjcgMzkuNTY2NiA1NS40MzAyIDM3LjM2NjhaIiBmaWxsPSJ3aGl0ZSIvPgo8cGF0aCBmaWxsLXJ1bGU9ImV2ZW5vZGQiIGNsaXAtcnVsZT0iZXZlbm9kZCIgZD0iTTYzLjg4MDMgNzMuMDYxQzU5LjcyODggNzMuMjA2NCA1NS41NTIzIDczLjI1NSA1MS4zNTA5IDczLjIwNjdDNTAuMjQxNyA3MS41MzgxIDQ5LjEwMDUgNjkuODg3IDQ3LjkyNzEgNjguMjUzMkM0Ni44NDUxIDY5Ljg4NCA0NS44MDA5IDcxLjUzNTEgNDQuNzk0OCA3My4yMDY3QzQwLjY5MDQgNzMuMjU1IDM2LjYxMSA3My4yMDY0IDMyLjU1NjcgNzMuMDYxQzM2LjM4NDkgNjcuMDIyMSA0MC4yNDU3IDYxLjAwMDMgNDQuMTM5MiA1NC45OTUzQzQ1LjcxNzMgNTMuMzQxOSA0Ny41ODcxIDUyLjkyOTIgNDkuNzQ4MyA1My43NTdDNTAuMzIyMyA1NC4wMzE3IDUwLjgzMjIgNTQuMzk2IDUxLjI3OCA1NC44NDk3QzU1LjUxMDkgNjAuOTAyNyA1OS43MTE3IDY2Ljk3MzIgNjMuODgwMyA3My4wNjFaIiBmaWxsPSJ3aGl0ZSIvPgo8cGF0aCBmaWxsLXJ1bGU9ImV2ZW5vZGQiIGNsaXAtcnVsZT0iZXZlbm9kZCIgZD0iTTE1LjM2NTIgNzMuMDYxMUMxOC45ODI1IDczLjIwNjUgMjIuNjI0NyA3My4yNTUgMjYuMjkyMSA3My4yMDY3QzIyLjYyNSA3My40MDA0IDE4LjkzNDIgNzMuNDAwNCAxNS4yMTk1IDczLjIwNjdDMTUuMjM3NiA3My4xMTgzIDE1LjI4NjEgNzMuMDY5OCAxNS4zNjUyIDczLjA2MTFaIiBmaWxsPSJ3aGl0ZSIvPgo8cGF0aCBmaWxsLXJ1bGU9ImV2ZW5vZGQiIGNsaXAtcnVsZT0iZXZlbm9kZCIgZD0iTTMyLjU1NjggNzMuMDYxMUMzNi42MTExIDczLjIwNjUgNDAuNjkwNCA3My4yNTUgNDQuNzk0OCA3My4yMDY3QzQwLjY5MDcgNzMuNDAwNCAzNi41NjI5IDczLjQwMDQgMzIuNDExMSA3My4yMDY3QzMyLjQyOTIgNzMuMTE4MyAzMi40Nzc3IDczLjA2OTggMzIuNTU2OCA3My4wNjExWiIgZmlsbD0id2hpdGUiLz4KPHBhdGggZmlsbC1ydWxlPSJldmVub2RkIiBjbGlwLXJ1bGU9ImV2ZW5vZGQiIGQ9Ik02My44ODAzIDczLjA2MTFDNjMuOTU5NCA3My4wNjk4IDY0LjAwNzkgNzMuMTE4MyA2NC4wMjYgNzMuMjA2N0M1OS43NzcxIDczLjQwMDUgNTUuNTUyIDczLjQwMDUgNTEuMzUwOSA3My4yMDY3QzU1LjU1MjMgNzMuMjU1IDU5LjcyODkgNzMuMjA2NSA2My44ODAzIDczLjA2MTFaIiBmaWxsPSJ3aGl0ZSIvPgo8L3N2Zz4=' export function createAvatar(seed: string): string { if (seed === 'info@agentic.so') { return defaultAgenticAvatar } if (seed === 'dev@agentic.so') { return defaultDevAgenticAvatar } return createAvatarImpl(identicon, { seed }).toDataUri() } ================================================ FILE: apps/api/src/lib/create-consumer-api-key.ts ================================================ import { sha256 } from '@agentic/platform-core' export async function createConsumerApiKey(): Promise { const hash = await sha256() return `sk-${hash}` } ================================================ FILE: apps/api/src/lib/deployments/get-deployment-by-id.ts ================================================ import { assert } from '@agentic/platform-core' import { db, eq, type RawDeployment, schema } from '@/db' /** * Finds the Deployment with the given id. * * Does not take care of ACLs. * * Returns `undefined` if not found. */ export async function getDeploymentById({ deploymentId, ...dbQueryOpts }: { deploymentId: string with?: { user?: true team?: true project?: true } }): Promise { assert(deploymentId, 400, 'Missing required deployment id') const deployment = await db.query.deployments.findFirst({ ...dbQueryOpts, where: eq(schema.deployments.id, deploymentId) }) return deployment } ================================================ FILE: apps/api/src/lib/deployments/normalize-deployment-version.ts ================================================ import { assert } from '@agentic/platform-core' import semver from 'semver' import type { RawProject } from '@/db' export function normalizeDeploymentVersion({ deploymentIdentifier, version: rawVersion, project }: { deploymentIdentifier: string version: string project: RawProject }): string | undefined { const version = semver.clean(rawVersion) assert(version, 400, `Invalid semver version "${rawVersion}"`) assert( semver.valid(version), 400, `Invalid semver version "${version}" for deployment "${deploymentIdentifier}"` ) const lastPublishedVersion = project.lastPublishedDeployment?.version assert( !lastPublishedVersion || semver.gt(version, lastPublishedVersion), 400, `Semver version "${version}" must be greater than the current published version "${lastPublishedVersion}" for deployment "${deploymentIdentifier}"` ) return version } ================================================ FILE: apps/api/src/lib/deployments/publish-deployment.ts ================================================ import { assert } from '@agentic/platform-core' import type { AuthenticatedHonoContext } from '@/lib/types' import { db, eq, type RawDeployment, schema } from '@/db' import { acl } from '@/lib/acl' import { normalizeDeploymentVersion } from './normalize-deployment-version' export async function publishDeployment( ctx: AuthenticatedHonoContext, { deployment, version: rawVersion }: { deployment: RawDeployment version: string } ): Promise { const project = await db.query.projects.findFirst({ where: eq(schema.projects.id, deployment.projectId), with: { lastPublishedDeployment: true } }) assert(project, 404, `Project not found "${deployment.projectId}"`) await acl(ctx, project, { label: 'Project' }) const version = normalizeDeploymentVersion({ deploymentIdentifier: deployment.identifier, project, version: rawVersion }) // TODO: enforce additional semver constraints // - pricing changes require major version update // - deployment shouldn't already be published? // - any others? // Update the deployment and project together in a transaction const [[updatedDeployment]] = await db.transaction(async (tx) => { return Promise.all([ // Update the deployment tx .update(schema.deployments) .set({ published: true, version }) .where(eq(schema.deployments.id, deployment.id)) .returning(), // Update the project tx .update(schema.projects) .set({ name: deployment.name, lastPublishedDeploymentId: deployment.id, lastPublishedDeploymentVersion: version }) .where(eq(schema.projects.id, project.id)) // TODO: add publishDeploymentLogEntry ]) }) assert( updatedDeployment, 500, `Failed to update deployment "${deployment.id}"` ) return updatedDeployment } ================================================ FILE: apps/api/src/lib/deployments/try-get-deployment-by-identifier.ts ================================================ import type { DefaultHonoContext } from '@agentic/platform-hono' import { assert } from '@agentic/platform-core' import { parseDeploymentIdentifier } from '@agentic/platform-validators' import { and, db, deploymentIdSchema, eq, type RawDeployment, schema } from '@/db' import { setPublicCacheControl } from '@/lib/cache-control' import type { AuthenticatedHonoContext } from '../types' /** * Attempts to find the Deployment matching the given deployment ID or * identifier. * * Throws a HTTP 404 error if not found. * * Does not take care of ACLs. */ export async function tryGetDeploymentByIdentifier( ctx: AuthenticatedHonoContext | DefaultHonoContext, { deploymentIdentifier, strict = false, ...dbQueryOpts }: { deploymentIdentifier: string strict?: boolean with?: { user?: true team?: true project?: true } } ): Promise { assert(deploymentIdentifier, 400, 'Missing required deployment identifier') // First check if the identifier is a deployment ID if (deploymentIdSchema.safeParse(deploymentIdentifier).success) { const deployment = await db.query.deployments.findFirst({ ...dbQueryOpts, where: eq(schema.deployments.id, deploymentIdentifier) }) assert(deployment, 404, `Deployment not found "${deploymentIdentifier}"`) setPublicCacheControl(ctx.res, '1h') return deployment } const parsedDeploymentIdentifier = parseDeploymentIdentifier( deploymentIdentifier, { strict } ) const { projectIdentifier, deploymentHash, deploymentVersion } = parsedDeploymentIdentifier deploymentIdentifier = parsedDeploymentIdentifier.deploymentIdentifier if (deploymentHash) { const deployment = await db.query.deployments.findFirst({ ...dbQueryOpts, where: eq(schema.deployments.identifier, deploymentIdentifier) }) assert(deployment, 404, `Deployment not found "${deploymentIdentifier}"`) setPublicCacheControl(ctx.res, '1h') return deployment } else if (deploymentVersion) { const project = await db.query.projects.findFirst({ where: eq(schema.projects.identifier, projectIdentifier) }) assert(project, 404, `Project not found "${projectIdentifier}"`) if (deploymentVersion === 'latest') { const deploymentId = project.lastPublishedDeploymentId || project.lastDeploymentId assert( deploymentId, 404, `Project has no published deployments (referenced by "${deploymentIdentifier}")` ) const deployment = await db.query.deployments.findFirst({ ...dbQueryOpts, where: eq(schema.deployments.id, deploymentId) }) assert( deployment, 404, `Deployment not found "${project.lastPublishedDeploymentId}" (referenced by "${deploymentIdentifier}")` ) setPublicCacheControl(ctx.res, '10s') return deployment } else if (deploymentVersion === 'dev') { assert( project.lastDeploymentId, 404, `Project has no published deployments (referenced by "${deploymentIdentifier}")` ) const deployment = await db.query.deployments.findFirst({ ...dbQueryOpts, where: eq(schema.deployments.id, project.lastDeploymentId) }) assert( deployment, 404, `Deployment not found "${project.lastDeploymentId}" (referenced by "${deploymentIdentifier}")` ) setPublicCacheControl(ctx.res, '10s') return deployment } else { const deployment = await db.query.deployments.findFirst({ ...dbQueryOpts, where: and( eq(schema.deployments.projectId, project.id), eq(schema.deployments.version, deploymentVersion) ) }) assert(deployment, 404, `Deployment not found "${deploymentIdentifier}"`) setPublicCacheControl(ctx.res, '1h') return deployment } } assert(false, 400, `Invalid Deployment identifier "${deploymentIdentifier}"`) } ================================================ FILE: apps/api/src/lib/ensure-auth-user.ts ================================================ import { assert } from '@agentic/platform-core' import type { AuthenticatedHonoContext } from '@/lib/types' import { db, eq, type RawUser, schema } from '@/db' export async function ensureAuthUser( ctx: AuthenticatedHonoContext ): Promise { let user = ctx.get('user') if (user) return user const userId = ctx.get('userId') assert(userId, 401, 'Unauthorized') user = await db.query.users.findFirst({ where: eq(schema.users.id, userId) }) assert(user, 401, 'Unauthorized') ctx.set('user', user) return user } ================================================ FILE: apps/api/src/lib/ensure-unique-namespace.ts ================================================ import { assert, sha256 } from '@agentic/platform-core' import { db, eq, schema } from '@/db' export async function ensureUniqueNamespace( namespace: string, { label = 'Namespace' }: { label?: string } = {} ) { namespace = namespace.toLocaleLowerCase() const [existingTeam, existingUser] = await Promise.all([ db.query.teams.findFirst({ where: eq(schema.teams.slug, namespace) }), db.query.users.findFirst({ where: eq(schema.users.username, namespace) }) ]) assert( !existingUser && !existingTeam, 409, `${label} "${namespace}" is not available` ) } export async function getUniqueNamespace( namespace?: string, { label = 'Namespace' }: { label?: string } = {} ) { namespace ??= `${label}_${(await sha256()).slice(0, 24)}` namespace = namespace .replaceAll(/[^a-zA-Z0-9_-]/g, '') .toLowerCase() .slice(0, schema.namespaceMaxLength - 1) let currentNamespace = namespace let attempts = 0 do { try { await ensureUniqueNamespace(namespace, { label }) return currentNamespace } catch (err) { if (++attempts > 10) { throw err } const suffix = (await sha256()).slice(0, 8) currentNamespace = `${namespace.slice(0, schema.namespaceMaxLength - 1 - suffix.length)}${suffix}` } } while (true) } ================================================ FILE: apps/api/src/lib/env.ts ================================================ import type { Simplify } from 'type-fest' import { parseZodSchema } from '@agentic/platform-core' import { envSchema as baseEnvSchema, parseEnv as parseBaseEnv } from '@agentic/platform-hono' import { z } from 'zod' export const envSchema = baseEnvSchema .extend({ DATABASE_URL: z.string().url(), AGENTIC_WEB_BASE_URL: z.string().url(), AGENTIC_GATEWAY_BASE_URL: z.string().url(), AGENTIC_STORAGE_BASE_URL: z .string() .url() .optional() .default('https://storage.agentic.so'), JWT_SECRET: z.string().nonempty(), PORT: z.coerce.number().default(3001), STRIPE_SECRET_KEY: z.string().nonempty(), STRIPE_WEBHOOK_SECRET: z.string().nonempty(), GITHUB_CLIENT_ID: z.string().nonempty(), GITHUB_CLIENT_SECRET: z.string().nonempty(), RESEND_API_KEY: z.string().nonempty(), // Used to make admin API calls from the API gateway AGENTIC_ADMIN_API_KEY: z.string().nonempty(), // Used to simplify recreating the demo `@agentic/search` project during // development while we're frequently resetting the database AGENTIC_SEARCH_PROXY_SECRET: z.string().nonempty(), S3_BUCKET: z.string().nonempty().optional().default('agentic'), S3_REGION: z.string().nonempty().optional().default('auto'), S3_ENDPOINT: z.string().nonempty().url(), S3_ACCESS_KEY_ID: z.string().nonempty(), S3_ACCESS_KEY_SECRET: z.string().nonempty() }) .strip() export type RawEnv = z.infer export function parseEnv(inputEnv: Record) { const baseEnv = parseBaseEnv({ SERVICE: 'api', ...inputEnv }) const env = parseZodSchema( envSchema, { ...inputEnv, ...baseEnv }, { error: 'Invalid environment variables' } ) const isStripeLive = env.STRIPE_SECRET_KEY.startsWith('sk_live_') const apiBaseUrl = baseEnv.isProd ? 'https://api.agentic.so' : 'http://localhost:3001' return { ...baseEnv, ...env, isStripeLive, apiBaseUrl } } export type Env = Simplify> // eslint-disable-next-line no-process-env export const env = parseEnv(process.env) ================================================ FILE: apps/api/src/lib/exit-hooks.ts ================================================ import { promisify } from 'node:util' import type { ServerType } from '@hono/node-server' import * as Sentry from '@sentry/node' import { asyncExitHook } from 'exit-hook' import restoreCursor from 'restore-cursor' import { db } from '@/db' export function initExitHooks({ server, timeoutMs = 10_000 }: { server: ServerType timeoutMs?: number }) { // Gracefully restore the cursor if run from a TTY restoreCursor() // Gracefully shutdown the HTTP server asyncExitHook( async function shutdownServerExitHook() { try { await promisify(server.close)() } catch { // TODO } }, { wait: timeoutMs } ) // Gracefully shutdown the postgres database connection asyncExitHook( async function shutdownDbExitHook() { try { if ('end' in db.$client) { await db.$client.end({ timeout: timeoutMs }) } } catch { // TODO } }, { wait: timeoutMs } ) // Gracefully flush Sentry events asyncExitHook( async function flushSentryExitHook() { await Sentry.flush(timeoutMs) }, { wait: timeoutMs } ) // TODO: On Node.js, log unhandledRejection, uncaughtException, and warning events } ================================================ FILE: apps/api/src/lib/external/github.ts ================================================ import ky from 'ky' import { Octokit } from 'octokit' import { env } from '@/lib/env' const USER_AGENT = 'agentic-platform' /** * GitHub (user-level) OAuth token response. * * @see https://docs.github.com/apps/oauth */ export interface GitHubUserTokenResponse { /** * The user access token (always starts with `ghu_`). * Example: `ghu_xxx…` */ access_token: string /** * Seconds until `access_token` expires. * Omitted (`undefined`) if you’ve disabled token expiration. * Constant `28800` (8 hours) when present. */ expires_in?: number /** * Refresh token for renewing the user access token (starts with `ghr_`). * Omitted (`undefined`) if you’ve disabled token expiration. */ refresh_token?: string /** * Seconds until `refresh_token` expires. * Omitted (`undefined`) if you’ve disabled token expiration. * Constant `15897600` (6 months) when present. */ refresh_token_expires_in?: number /** * Scopes granted to the token. * Always an empty string because the token is limited to * the intersection of app-level and user-level permissions. */ scope: '' /** * Token type – always `'bearer'`. */ token_type: 'bearer' } export function getGitHubClient({ accessToken }: { accessToken: string }): Octokit { return new Octokit({ auth: accessToken }) } export async function exchangeGitHubOAuthCodeForAccessToken({ code, clientId = env.GITHUB_CLIENT_ID, clientSecret = env.GITHUB_CLIENT_SECRET, redirectUri }: { code: string clientId?: string clientSecret?: string redirectUri?: string }): Promise { return ky .post('https://github.com/login/oauth/access_token', { json: { code, client_id: clientId, client_secret: clientSecret, redirect_uri: redirectUri }, headers: { 'user-agent': USER_AGENT } }) .json() } ================================================ FILE: apps/api/src/lib/external/resend.ts ================================================ import { ResendEmailClient } from '@agentic/platform-emails' import { env } from '@/lib/env' export const resend = new ResendEmailClient({ apiKey: env.RESEND_API_KEY }) ================================================ FILE: apps/api/src/lib/external/sentry.ts ================================================ import * as Sentry from '@sentry/node' // This MUST be run before anything else (imported first in the root file). // No other imports (like env) should be imported in this file. Sentry.init({ dsn: process.env.SENTRY_DSN, // eslint-disable-line no-process-env environment: process.env.ENVIRONMENT || 'development', // eslint-disable-line no-process-env integrations: [Sentry.extraErrorDataIntegration()], tracesSampleRate: 1.0, sendDefaultPii: true }) ================================================ FILE: apps/api/src/lib/external/stripe.ts ================================================ import Stripe from 'stripe' import { env } from '@/lib/env' const version = '2025-06-30.basil' export const stripe = new Stripe(env.STRIPE_SECRET_KEY, { apiVersion: version, appInfo: { name: 'transitive-bullshit/agentic', version, url: 'https://agentic.so' } }) ================================================ FILE: apps/api/src/lib/middleware/authenticate.ts ================================================ import { assert } from '@agentic/platform-core' import { authUserSchema } from '@agentic/platform-types' import { createMiddleware } from 'hono/factory' import { verify } from 'hono/jwt' import type { RawUser } from '@/db' import type { AuthenticatedHonoEnv } from '@/lib/types' import { env } from '@/lib/env' import { timingSafeCompare } from '@/lib/utils' export const authenticate = createMiddleware( async function authenticateMiddleware(ctx, next) { const credentials = ctx.req.raw.headers.get('Authorization') assert(credentials, 401, 'Unauthorized') const parts = credentials.split(/\s+/) assert( parts.length === 1 || (parts.length === 2 && parts[0]?.toLowerCase() === 'bearer'), 401, 'Unauthorized' ) const token = parts.at(-1) assert(token, 401, 'Unauthorized') // TODO: Use a more secure way to authenticate gateway admin requests. if (timingSafeCompare(token, env.AGENTIC_ADMIN_API_KEY)) { ctx.set('userId', 'admin') ctx.set('user', { id: 'admin', name: 'Admin', username: 'admin', role: 'admin', email: 'admin@agentic.so', isEmailVerified: true, image: undefined, stripeCustomerId: undefined, createdAt: new Date().toISOString(), updatedAt: new Date().toISOString(), deletedAt: undefined } as RawUser) } else { const payload = await verify(token, env.JWT_SECRET) assert(payload, 401, 'Unauthorized') const parsedAuthUser = authUserSchema.safeParse(payload) assert(parsedAuthUser.success, 401, 'Unauthorized') ctx.set('userId', parsedAuthUser.data.id) } await next() } ) ================================================ FILE: apps/api/src/lib/middleware/index.ts ================================================ export * from './authenticate' export * from './me' export * from './team' export { accessLogger, compress, cors, init, responseTime, sentry, unless } from '@agentic/platform-hono' ================================================ FILE: apps/api/src/lib/middleware/me.ts ================================================ import { createMiddleware } from 'hono/factory' import type { AuthenticatedHonoEnv } from '@/lib/types' import { ensureAuthUser } from '../ensure-auth-user' export const me = createMiddleware( async function meMiddleware(ctx, next) { const regex = /^\/(me)(\/|$)/ if (regex.test(ctx.req.path)) { const user = await ensureAuthUser(ctx) if (user) { // TODO: redirect instead? ctx.req.path = ctx.req.path.replace(regex, `/users/${user.id}$2`) } } await next() } ) ================================================ FILE: apps/api/src/lib/middleware/team.ts ================================================ import { assert } from '@agentic/platform-core' import { createMiddleware } from 'hono/factory' import type { AuthenticatedHonoEnv } from '@/lib/types' import { and, db, eq, schema } from '@/db' import { aclTeamMember } from '@/lib/acl-team-member' // TODO: Instead of accepting `teamId` query param, change the authenticate // middleware to accept a different JWT payload and then use that to // determine the intended user and/or team. export const team = createMiddleware( async function teamMiddleware(ctx, next) { const teamId = ctx.req.query('teamId') const userId = ctx.get('userId') if (teamId && userId) { const teamMember = await db.query.teamMembers.findFirst({ where: and( eq(schema.teamMembers.teamId, teamId), eq(schema.teamMembers.userId, userId) ) }) assert(teamMember, 403, 'Unauthorized') await aclTeamMember(ctx, { teamMember }) ctx.set('teamMember', teamMember) } await next() } ) ================================================ FILE: apps/api/src/lib/openapi-utils.ts ================================================ import { fromError } from 'zod-validation-error' import type { HonoApp } from './types' export const openapiErrorResponses = { 400: { $ref: '#/components/responses/400' }, 401: { $ref: '#/components/responses/401' }, 403: { $ref: '#/components/responses/403' } } as const export const openapiErrorResponse404 = { 404: { $ref: '#/components/responses/404' } } as const export const openapiErrorResponse409 = { 409: { $ref: '#/components/responses/409' } } as const export const openapiErrorResponse410 = { 410: { $ref: '#/components/responses/410' } } as const // No `as const` because zod openapi doesn't support readonly for `security` export const openapiAuthenticatedSecuritySchemas = [ { Bearer: [] } ] const openapiErrorContent = { 'application/json': { schema: { type: 'object' as const, properties: { error: { type: 'string' as const }, requestId: { type: 'string' as const } }, required: ['error', 'requestId'] } } } export function registerOpenAPIErrorResponses(app: HonoApp) { app.openAPIRegistry.registerComponent('responses', '400', { description: 'Bad Request', content: openapiErrorContent }) app.openAPIRegistry.registerComponent('responses', '401', { description: 'Unauthorized', content: openapiErrorContent }) app.openAPIRegistry.registerComponent('responses', '403', { description: 'Forbidden', content: openapiErrorContent }) app.openAPIRegistry.registerComponent('responses', '404', { description: 'Not Found', content: openapiErrorContent }) app.openAPIRegistry.registerComponent('responses', '409', { description: 'Conflict', content: openapiErrorContent }) app.openAPIRegistry.registerComponent('responses', '410', { description: 'Gone', content: openapiErrorContent }) } export function defaultHook(result: any, ctx: any) { if (!result.success) { const requestId = ctx.get('requestId') return ctx.json( { error: fromError(result.error).toString(), requestId }, 400 ) } } ================================================ FILE: apps/api/src/lib/projects/try-get-project-by-identifier.ts ================================================ import { assert } from '@agentic/platform-core' import { parseProjectIdentifier } from '@agentic/platform-validators' import type { AuthenticatedHonoContext } from '@/lib/types' import { db, eq, projectIdSchema, type RawProject, schema } from '@/db' /** * Attempts to find the Project matching the given ID or identifier. * * Throws a HTTP 404 error if not found. * * Does not take care of ACLs. */ export async function tryGetProjectByIdentifier( ctx: AuthenticatedHonoContext, { projectIdentifier, strict = false, ...dbQueryOpts }: { projectIdentifier: string strict?: boolean with?: { user?: true team?: true lastPublishedproject?: true lastproject?: true } } ): Promise { assert(projectIdentifier, 400, 'Missing required project identifier') // First check if the identifier is a project ID if (projectIdSchema.safeParse(projectIdentifier).success) { const project = await db.query.projects.findFirst({ ...dbQueryOpts, where: eq(schema.projects.id, projectIdentifier) }) assert(project, 404, `Project not found "${projectIdentifier}"`) return project } const parsedProjectIdentifier = parseProjectIdentifier(projectIdentifier, { strict }) projectIdentifier = parsedProjectIdentifier.projectIdentifier const project = await db.query.projects.findFirst({ ...dbQueryOpts, where: eq(schema.projects.identifier, projectIdentifier) }) assert(project, 404, `Project not found "${projectIdentifier}"`) return project } ================================================ FILE: apps/api/src/lib/storage.test.ts ================================================ import { describe, expect, test } from 'vitest' import { deleteStorageObject, getStorageObject, putStorageObject, uploadFileUrlToStorage } from './storage' describe('Storage', () => { test('putObject, getObject, deleteObject', async () => { await putStorageObject('test.txt', 'hello world', { ContentType: 'text/plain' }) const obj = await getStorageObject('test.txt') expect(obj.ContentType).toEqual('text/plain') const body = await obj.Body?.transformToString() expect(body).toEqual('hello world') const res = await deleteStorageObject('test.txt') expect(res.$metadata.httpStatusCode).toEqual(204) }) test('uploadFileUrlToStorage url', async () => { const url = await uploadFileUrlToStorage( 'https://agentic.so/agentic-icon-circle-light.svg', { prefix: '@dev/test' } ) expect(url).toBeTruthy() expect(new URL(url).origin).toEqual('https://storage.agentic.so') expect(url).toMatchSnapshot() }) test('uploadFileUrlToStorage data-uri', async () => { const url = await uploadFileUrlToStorage( 'data:image/svg+xml,%3Csvg%20xmlns%3D%22http%3A%2F%2Fwww.w3.org%2F2000%2Fsvg%22%20width%3D%22400%22%20height%3D%22400%22%20viewBox%3D%220%200%20124%20124%22%20fill%3D%22none%22%3E%3Crect%20width%3D%22124%22%20height%3D%22124%22%20rx%3D%2224%22%20fill%3D%22%23F97316%22%2F%3E%3Cpath%20d%3D%22M19.375%2036.7818V100.625C19.375%20102.834%2021.1659%20104.625%2023.375%20104.625H87.2181C90.7818%20104.625%2092.5664%20100.316%2090.0466%2097.7966L26.2034%2033.9534C23.6836%2031.4336%2019.375%2033.2182%2019.375%2036.7818Z%22%20fill%3D%22white%22%2F%3E%3Ccircle%20cx%3D%2263.2109%22%20cy%3D%2237.5391%22%20r%3D%2218.1641%22%20fill%3D%22black%22%2F%3E%3Crect%20opacity%3D%220.4%22%20x%3D%2281.1328%22%20y%3D%2280.7198%22%20width%3D%2217.5687%22%20height%3D%2217.3876%22%20rx%3D%224%22%20transform%3D%22rotate(-45%2081.1328%2080.7198)%22%20fill%3D%22%23FDBA74%22%2F%3E%3C%2Fsvg%3E', { prefix: '@dev/test' } ) expect(url).toBeTruthy() expect(new URL(url).origin).toEqual('https://storage.agentic.so') expect(url).toMatchSnapshot() }) test('uploadFileUrlToStorage data-uri 2', async () => { const url = await uploadFileUrlToStorage( 'data:application/octet-stream;base64,IyBUZXN0IEV2ZXJ5dGhpbmcgT3BlbkFQSQoKVGhpcyBpcyB0ZXN0aW5nICoqcmVhZG1lIHJlbmRlcmluZyoqLgoKIyMgTWlzYwoKLSBbIF0gSXRlbSAxCi0gWyBdIEl0ZW0gMgotIFt4XSBJdGVtIDMKCi0tLQoKLSBfaXRhbGljXwotICoqYm9sZCoqCi0gW2xpbmtdKGh0dHBzOi8vd3d3Lmdvb2dsZS5jb20pCi0gYGNvZGVgCgojIyBDb2RlCgpgYGB0cwpjb25zdCBhID0gMQoKZXhwb3J0IGZ1bmN0aW9uIGZvbygpIHsKICBjb25zb2xlLmxvZygnaGVsbG8gd29ybGQnKQp9CmBgYAoKIyMgSW1hZ2VzCgohW0ltYWdlXShodHRwczovL3BsYWNlaG9sZC5jby82MDB4NDAwKQo=', { prefix: '@dev/test' } ) expect(url).toBeTruthy() expect(new URL(url).origin).toEqual('https://storage.agentic.so') expect(url).toMatchSnapshot() }) }) ================================================ FILE: apps/api/src/lib/storage.ts ================================================ import { sha256 } from '@agentic/platform-core' import { DeleteObjectCommand, type DeleteObjectCommandInput, GetObjectCommand, type GetObjectCommandInput, PutObjectCommand, type PutObjectCommandInput, S3Client } from '@aws-sdk/client-s3' import { getSignedUrl } from '@aws-sdk/s3-request-presigner' import { fileTypeFromBuffer } from 'file-type' import ky from 'ky' import { lookup as lookupMimeType } from 'mrmime' import { env } from './env' // This storage client is designed to work with any S3-compatible storage provider. // For Cloudflare R2, see https://developers.cloudflare.com/r2/examples/aws/aws-sdk-js-v3/ const STORAGE_DOMAIN = 'storage.agentic.so' const Bucket = env.S3_BUCKET export const storageClient = new S3Client({ region: env.S3_REGION, endpoint: env.S3_ENDPOINT, credentials: { accessKeyId: env.S3_ACCESS_KEY_ID, secretAccessKey: env.S3_ACCESS_KEY_SECRET }, requestChecksumCalculation: 'WHEN_REQUIRED', responseChecksumValidation: 'WHEN_REQUIRED' }) // This ensures that buckets are created automatically if they don't exist on // Cloudflare R2. It won't affect other providers. // @see https://developers.cloudflare.com/r2/examples/aws/custom-header/ storageClient.middlewareStack.add( (next, _) => async (args) => { const r = args.request as RequestInit r.headers = { 'cf-create-bucket-if-missing': 'true', ...r.headers } return next(args) }, { step: 'build', name: 'customHeaders' } ) export async function getStorageObject( key: string, opts?: Omit ) { return storageClient.send(new GetObjectCommand({ Bucket, Key: key, ...opts })) } export async function putStorageObject( key: string, value: PutObjectCommandInput['Body'], opts?: Omit ) { return storageClient.send( new PutObjectCommand({ Bucket, Key: key, Body: value, ...opts }) ) } export async function deleteStorageObject( key: string, opts?: Omit ) { return storageClient.send( new DeleteObjectCommand({ Bucket, Key: key, ...opts }) ) } export function getStorageObjectInternalUrl(key: string) { return `${env.S3_ENDPOINT}/${Bucket}/${key}` } export function getStorageObjectPublicUrl(key: string) { return `${env.AGENTIC_STORAGE_BASE_URL}/${key}` } // TODO: Signed uploads don't seem to be working; getting a signature mismatch // error, and no idea why. Switched to using non-presigned URL uploads for now, // but this will be necessary in the future, for instance, for the web client // to upload files. export async function getStorageSignedUploadUrl( key: string, { expiresIn = 5 * 60 * 1000 // 5 minutes }: { expiresIn?: number } = {} ) { return getSignedUrl( storageClient, new PutObjectCommand({ Bucket, Key: key }), { expiresIn } ) } export async function uploadFileUrlToStorage( inputUrl: string, { prefix }: { prefix: string } ): Promise { let source: URL try { source = new URL(inputUrl) } catch { // Not a URL throw new Error(`Invalid source file URL: ${inputUrl}`) } if (source.hostname === STORAGE_DOMAIN) { // The source is already a public URL hosted on Agentic's blob storage. return source.toString() } const sourceBuffer = await ky.get(source).arrayBuffer() const [hash, inferredFileType] = await Promise.all([ sha256(sourceBuffer), fileTypeFromBuffer(sourceBuffer) ]) const maybeSourceExt = source.pathname.split('.').at(-1) const maybeSourceExt2 = maybeSourceExt ? /^[a-z]+$/i.test(maybeSourceExt) ? maybeSourceExt : undefined : undefined const maybeSourceMime = source.protocol === 'data:' ? source.pathname.split(',')[0]?.split(';')[0] : undefined const sourceExt0 = maybeSourceExt2 ?? (maybeSourceMime && maybeSourceMime !== 'application/octet-stream' ? maybeSourceMime.split('/')[1]?.split('+')[0] : undefined) const sourceExt = sourceExt0 === 'markdown' ? 'md' : sourceExt0 const fileType = inferredFileType ?? (sourceExt ? { ext: sourceExt, mime: lookupMimeType(sourceExt) } : undefined) const filename = fileType?.ext ? `${hash}.${fileType.ext}` : hash const key = `${prefix}/${filename}` const publicObjectUrl = getStorageObjectPublicUrl(key) // console.log('uploading to r2', { // key, // source, // sourceExt, // maybeSourceMime, // maybeSourceExt, // maybeSourceExt2, // inputUrl, // fileType, // publicObjectUrl // }) try { // Check if the object already exists. await ky.head(publicObjectUrl) } catch { const body = Buffer.from(sourceBuffer) // Object doesn't exist yet, so upload it. try { await putStorageObject(key, body, { ContentType: fileType?.mime ?? 'application/octet-stream' }) } catch (err: any) { const error = await err.response.text() // eslint-disable-next-line no-console console.error('error uploading to r2', err.message, error) throw err } } return publicObjectUrl } ================================================ FILE: apps/api/src/lib/temp ================================================ import ky from 'ky' import { env } from './env' const USER_AGENT = 'agentic-platform' /** * GitHub (user-level) OAuth token response. * * @see https://docs.github.com/apps/oauth */ export interface GitHubUserTokenResponse { /** * The user access token (always starts with `ghu_`). * Example: `ghu_xxx…` */ access_token: string /** * Seconds until `access_token` expires. * Omitted (`undefined`) if you’ve disabled token expiration. * Constant `28800` (8 hours) when present. */ expires_in?: number /** * Refresh token for renewing the user access token (starts with `ghr_`). * Omitted (`undefined`) if you’ve disabled token expiration. */ refresh_token?: string /** * Seconds until `refresh_token` expires. * Omitted (`undefined`) if you’ve disabled token expiration. * Constant `15897600` (6 months) when present. */ refresh_token_expires_in?: number /** * Scopes granted to the token. * Always an empty string because the token is limited to * the intersection of app-level and user-level permissions. */ scope: '' /** * Token type – always `'bearer'`. */ token_type: 'bearer' } export interface GitHubUser { login: string id: number user_view_type?: string node_id: string avatar_url: string gravatar_id: string | null url: string html_url: string followers_url: string following_url: string gists_url: string starred_url: string subscriptions_url: string organizations_url: string repos_url: string events_url: string received_events_url: string type: string site_admin: boolean name: string | null company: string | null blog: string | null location: string | null email: string | null notification_email?: string | null hireable: boolean | null bio: string | null twitter_username?: string | null public_repos: number public_gists: number followers: number following: number created_at: string updated_at: string plan?: { collaborators: number name: string space: number private_repos: number [k: string]: unknown } private_gists?: number total_private_repos?: number owned_private_repos?: number disk_usage?: number collaborators?: number } export interface GitHubUserEmail { email: string primary: boolean verified: boolean visibility?: string | null } export async function exchangeOAuthCodeForAccessToken({ code, clientId = env.GITHUB_CLIENT_ID, clientSecret = env.GITHUB_CLIENT_SECRET, redirectUri }: { code: string clientId?: string clientSecret?: string redirectUri?: string }): Promise { return ky .post('https://github.com/login/oauth/access_token', { json: { code, client_id: clientId, client_secret: clientSecret, redirect_uri: redirectUri }, headers: { 'user-agent': USER_AGENT } }) .json() } export async function getMe({ token }: { token: string }): Promise { return ky .get('https://api.github.com/user', { headers: { Authorization: `Bearer ${token}`, 'user-agent': USER_AGENT } }) .json() } export async function getUserEmails({ token }: { token: string }): Promise { return ky .get('https://api.github.com/user/emails', { headers: { Authorization: `Bearer ${token}`, 'user-agent': USER_AGENT } }) .json() } // TODO: currently unused // export type NullToUndefinedDeep = T extends null // ? undefined // : T extends Date // ? T // : T extends readonly (infer U)[] // ? NullToUndefinedDeep[] // : T extends object // ? { [K in keyof T]: NullToUndefinedDeep } // : T // // TODO: currently unused // export type UndefinedToNullDeep = T extends undefined // ? T | null // : T extends Date // ? T | null // : T extends readonly (infer U)[] // ? UndefinedToNullDeep[] // : T extends object // ? { [K in keyof T]: UndefinedToNullDeep } // : T | null // // TODO: currently unused // export type UndefinedValuesToNullableValues = T extends object // ? { [K in keyof T]: T[K] extends undefined ? T[K] | null : T[K] } // : T ================================================ FILE: apps/api/src/lib/types.ts ================================================ import type { DefaultHonoBindings, DefaultHonoEnv, DefaultHonoVariables } from '@agentic/platform-hono' import type { OpenAPIHono } from '@hono/zod-openapi' import type { Context } from 'hono' import type { Simplify } from 'type-fest' import type { RawTeamMember, RawUser } from '@/db' import type { Env } from './env' export type AuthenticatedHonoVariables = Simplify< DefaultHonoVariables & { userId: string user?: RawUser teamMember?: RawTeamMember } > export type AuthenticatedHonoBindings = Simplify export type AuthenticatedHonoEnv = { Bindings: AuthenticatedHonoBindings Variables: AuthenticatedHonoVariables } export type AuthenticatedHonoContext = Context export type HonoApp = OpenAPIHono export type AuthenticatedHonoApp = OpenAPIHono ================================================ FILE: apps/api/src/lib/utils.ts ================================================ import { timingSafeEqual } from 'node:crypto' export function timingSafeCompare(a: string, b: string): boolean { if (typeof a !== 'string' || typeof b !== 'string') { return false } if (a.length !== b.length) { return false } return timingSafeEqual(Buffer.from(a), Buffer.from(b)) } ================================================ FILE: apps/api/src/oauth-redirect.ts ================================================ import type { DefaultHonoEnv } from '@agentic/platform-hono' import type { OpenAPIHono } from '@hono/zod-openapi' import { assert } from '@agentic/platform-core' export function registerOAuthRedirect(app: OpenAPIHono) { return app.all('/oauth/callback', async (ctx) => { const logger = ctx.get('logger') if (ctx.req.query('state')) { const { state: state64, ...query } = ctx.req.query() // google oauth + others const { uri, ...state } = JSON.parse( Buffer.from(state64!, 'base64').toString() ) as any assert( uri, 404, `Error oauth redirect not found "${new URLSearchParams(ctx.req.query()).toString()}"` ) const searchParams = new URLSearchParams({ ...state, ...query }) const redirectUri = `${uri}?${searchParams.toString()}` logger.info( 'OAUTH CALLBACK', ctx.req.method, ctx.req.url, ctx.req.query(), '=>', redirectUri ) return ctx.redirect(redirectUri) } else { // github oauth // https://developer.github.com/apps/building-oauth-apps/authorizing-oauth-apps/#redirect-urls const { uri, ...params } = ctx.req.query() assert( uri, 404, `Error oauth redirect not found "${new URLSearchParams(ctx.req.query()).toString()}"` ) const searchParams = new URLSearchParams(params) const redirectUri = `${uri}?${searchParams.toString()}` logger.info( 'OAUTH CALLBACK', ctx.req.method, ctx.req.url, ctx.req.query(), '=>', redirectUri ) return ctx.redirect(redirectUri) } }) } ================================================ FILE: apps/api/src/reset.d.ts ================================================ import '@fisch0920/config/ts-reset' ================================================ FILE: apps/api/src/server.ts ================================================ import '@/lib/external/sentry' import { type DefaultHonoEnv, errorHandler } from '@agentic/platform-hono' import { serve } from '@hono/node-server' import { OpenAPIHono } from '@hono/zod-openapi' import * as Sentry from '@sentry/node' import { apiV1 } from '@/api-v1' import { env } from '@/lib/env' import * as middleware from '@/lib/middleware' import { initExitHooks } from './lib/exit-hooks' // import { registerOAuthRedirect } from './oauth-redirect' export const app = new OpenAPIHono() app.onError(errorHandler) app.use(middleware.sentry()) app.use(middleware.compress()) app.use( middleware.cors({ origin: '*', allowHeaders: ['Content-Type', 'Authorization'], allowMethods: ['POST', 'GET', 'OPTIONS'], exposeHeaders: ['Content-Length'], maxAge: 600, credentials: true }) ) app.use(middleware.init) app.use(middleware.accessLogger) app.use(middleware.responseTime) // TODO: top-level auth routes // registerOAuthRedirect(app) // Mount all v1 API routes app.route('/v1', apiV1) app.doc31('/docs', { openapi: '3.1.0', info: { title: 'Agentic', version: '0.1.0' } }) const server = serve({ fetch: (req, bindings) => app.fetch(req, { ...bindings, ...env, sentry: Sentry }), port: env.PORT }) initExitHooks({ server }) // eslint-disable-next-line no-console console.log(`Server running on port ${env.PORT}`) ================================================ FILE: apps/api/tsconfig.json ================================================ { "extends": "@fisch0920/config/tsconfig-node", "compilerOptions": { "baseUrl": ".", "paths": { "@/*": ["src/*"] } }, "include": ["src", "*.config.ts"], "exclude": ["node_modules", "dist"] } ================================================ FILE: apps/api/tsup.config.ts ================================================ import { defineConfig } from 'tsup' export default defineConfig([ { entry: ['src/server.ts'], outDir: 'dist', target: 'node22', platform: 'node', format: ['esm'], splitting: false, sourcemap: true, minify: false, shims: true, dts: true } ]) ================================================ FILE: apps/api/vitest.config.ts ================================================ import tsconfigPaths from 'vite-tsconfig-paths' import { defineConfig } from 'vitest/config' export default defineConfig({ plugins: [tsconfigPaths()], test: { environment: 'node', globals: true, watch: false, restoreMocks: true } }) ================================================ FILE: apps/e2e/bin/deploy-fixtures.ts ================================================ import { deployProjects } from '../src/deploy-projects' import { devClient } from '../src/dev-client' import { fixtures } from '../src/dev-fixtures' async function main() { console.log('\n\nDeploying dev fixtures...\n\n') const deployments = await deployProjects(fixtures, { client: devClient }) console.log(JSON.stringify(deployments, null, 2)) // eslint-disable-next-line unicorn/no-process-exit process.exit(0) } await main() ================================================ FILE: apps/e2e/bin/publish-fixtures.ts ================================================ import { deployProjects } from '../src/deploy-projects' import { devClient } from '../src/dev-client' import { fixtures } from '../src/dev-fixtures' import { publishDeployments } from '../src/publish-deployments' async function main() { console.log('\n\nDeploying dev fixtures...\n\n') const deployments = await deployProjects(fixtures, { client: devClient }) console.log(JSON.stringify(deployments, null, 2)) console.log('\n\nPublishing dev fixture deployments...\n\n') const publishedDeployments = await publishDeployments(deployments, { client: devClient }) console.log(JSON.stringify(publishedDeployments, null, 2)) // eslint-disable-next-line unicorn/no-process-exit process.exit(0) } await main() ================================================ FILE: apps/e2e/bin/seed-db.ts ================================================ import { AgenticApiClient } from '@agentic/platform-api-client' import { examples } from '../src/agentic-examples' import { deployProjects } from '../src/deploy-projects' import { fixtures } from '../src/dev-fixtures' import { env, isProd } from '../src/env' import { publishDeployments } from '../src/publish-deployments' export const client = new AgenticApiClient({ apiBaseUrl: env.AGENTIC_API_BASE_URL }) async function main() { { console.log('\n\nCreating "dev" user...\n\n') const devAuthSession = await client.signUpWithPassword({ username: 'dev', email: env.AGENTIC_DEV_EMAIL, password: env.AGENTIC_DEV_PASSWORD }) console.log(JSON.stringify(devAuthSession, null, 2)) console.warn( `\n\nREMEMBER TO UPDATE "AGENTIC_DEV_ACCESS_TOKEN" in e2e/.env${isProd ? '.production' : ''}\n\n` ) console.log('\n\nDeploying dev fixtures...\n\n') const deployments = await deployProjects(fixtures, { client }) console.log(JSON.stringify(deployments, null, 2)) console.log('\n\nPublishing dev fixture deployments...\n\n') const publishedDeployments = await publishDeployments(deployments, { client }) console.log(JSON.stringify(publishedDeployments, null, 2)) } { console.log('\n\nCreating "agentic" user...\n\n') const agenticAuthSession = await client.signUpWithPassword({ username: 'agentic', email: env.AGENTIC_AGENTIC_EMAIL, password: env.AGENTIC_AGENTIC_PASSWORD }) console.log(JSON.stringify(agenticAuthSession, null, 2)) console.log('\n\nDeploying example projects...\n\n') const deployments = await deployProjects(examples, { client }) console.log(JSON.stringify(deployments, null, 2)) console.log('\n\nPublishing example project deployments...\n\n') const publishedDeployments = await publishDeployments(deployments, { client }) console.log(JSON.stringify(publishedDeployments, null, 2)) } // eslint-disable-next-line unicorn/no-process-exit process.exit(0) } await main() ================================================ FILE: apps/e2e/package.json ================================================ { "name": "@agentic/platform-e2e-tests", "private": true, "version": "8.4.4", "description": "Internal Agentic platform E2E tests.", "author": "Travis Fischer ", "license": "AGPL-3.0", "repository": { "type": "git", "url": "git+https://github.com/transitive-bullshit/agentic.git", "directory": "apps/e2e" }, "type": "module", "scripts": { "deploy-fixtures": "dotenvx run -- tsx bin/deploy-fixtures.ts", "deploy-fixtures:prod": "dotenvx run -o -f .env.production -- tsx bin/deploy-fixtures.ts", "publish-fixtures": "dotenvx run -- tsx bin/publish-fixtures.ts", "publish-fixtures:prod": "dotenvx run -o -f .env.production -- tsx bin/publish-fixtures.ts", "seed-db": "dotenvx run -- tsx bin/seed-db.ts", "seed-db:prod": "dotenvx run -o -f .env.production -- tsx bin/seed-db.ts", "test": "run-s test:*", "test:typecheck": "tsc --noEmit", "e2e": "dotenvx run -- vitest run", "e2e-http": "dotenvx run -- vitest run src/http-e2e.test.ts", "e2e-mcp": "dotenvx run -- vitest run src/mcp-e2e.test.ts", "e2e:prod": "dotenvx run -o -f .env.production -- vitest run", "e2e-http:prod": "dotenvx run -o -f .env.production -- vitest run src/http-e2e.test.ts", "e2e-mcp:prod": "dotenvx run -o -f .env.production -- vitest run src/mcp-e2e.test.ts" }, "dependencies": { "dotenv": "catalog:", "ky": "catalog:", "p-map": "catalog:", "p-times": "catalog:", "semver": "catalog:" }, "devDependencies": { "@agentic/platform": "workspace:*", "@agentic/platform-api-client": "workspace:*", "@agentic/platform-core": "workspace:*", "@agentic/platform-fixtures": "workspace:*", "@agentic/platform-types": "workspace:*", "@modelcontextprotocol/sdk": "catalog:", "@types/semver": "catalog:", "fast-content-type-parse": "catalog:" } } ================================================ FILE: apps/e2e/src/__snapshots__/http-e2e.test.ts.snap ================================================ // Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html exports[`HTTP => MCP origin basic "add" tool > 6.0: POST @dev/test-basic-mcp/add 1`] = ` [ { "text": "42", "type": "text", }, ] `; exports[`HTTP => MCP origin basic "add" tool > 6.1: GET @dev/test-basic-mcp/add 1`] = ` [ { "text": "42", "type": "text", }, ] `; exports[`HTTP => OpenAPI origin basic GET caching > 4.0: GET @dev/test-basic-openapi/getPost 1`] = ` { "body": "aut dicta possimus sint mollitia voluptas commodi quo doloremque iste corrupti reiciendis voluptatem eius rerum sit cumque quod eligendi laborum minima perferendis recusandae assumenda consectetur porro architecto ipsum ipsam", "id": 13, "title": "dolorum ut in voluptas mollitia et saepe quo animi", "userId": 2, } `; exports[`HTTP => OpenAPI origin basic GET caching > 4.1: GET @dev/test-basic-openapi/getPost?postId=13 1`] = ` { "body": "aut dicta possimus sint mollitia voluptas commodi quo doloremque iste corrupti reiciendis voluptatem eius rerum sit cumque quod eligendi laborum minima perferendis recusandae assumenda consectetur porro architecto ipsum ipsam", "id": 13, "title": "dolorum ut in voluptas mollitia et saepe quo animi", "userId": 2, } `; exports[`HTTP => OpenAPI origin basic GET caching > 4.2: GET @dev/test-basic-openapi/get_post?postId=13 1`] = ` { "body": "aut dicta possimus sint mollitia voluptas commodi quo doloremque iste corrupti reiciendis voluptatem eius rerum sit cumque quod eligendi laborum minima perferendis recusandae assumenda consectetur porro architecto ipsum ipsam", "id": 13, "title": "dolorum ut in voluptas mollitia et saepe quo animi", "userId": 2, } `; exports[`HTTP => OpenAPI origin basic POST caching > 5.0: POST @dev/test-basic-openapi/get_post 1`] = ` { "body": "aut dicta possimus sint mollitia voluptas commodi quo doloremque iste corrupti reiciendis voluptatem eius rerum sit cumque quod eligendi laborum minima perferendis recusandae assumenda consectetur porro architecto ipsum ipsam", "id": 13, "title": "dolorum ut in voluptas mollitia et saepe quo animi", "userId": 2, } `; exports[`HTTP => OpenAPI origin basic POST caching > 5.1: POST @dev/test-basic-openapi/get_post 1`] = ` { "body": "aut dicta possimus sint mollitia voluptas commodi quo doloremque iste corrupti reiciendis voluptatem eius rerum sit cumque quod eligendi laborum minima perferendis recusandae assumenda consectetur porro architecto ipsum ipsam", "id": 13, "title": "dolorum ut in voluptas mollitia et saepe quo animi", "userId": 2, } `; exports[`HTTP => OpenAPI origin basic bypass caching > 3.0: GET @dev/test-basic-openapi/getPost 1`] = ` { "body": "consectetur animi nesciunt iure dolore enim quia ad veniam autem ut quam aut nobis et est aut quod aut provident voluptas autem voluptas", "id": 9, "title": "nesciunt iure omnis dolorem tempora et accusantium", "userId": 1, } `; exports[`HTTP => OpenAPI origin basic bypass caching > 3.1: GET @dev/test-basic-openapi/getPost?postId=9 1`] = ` { "body": "consectetur animi nesciunt iure dolore enim quia ad veniam autem ut quam aut nobis et est aut quod aut provident voluptas autem voluptas", "id": 9, "title": "nesciunt iure omnis dolorem tempora et accusantium", "userId": 1, } `; exports[`HTTP => OpenAPI origin basic bypass caching > 3.2: GET @dev/test-basic-openapi/get_post?postId=9 1`] = ` { "body": "consectetur animi nesciunt iure dolore enim quia ad veniam autem ut quam aut nobis et est aut quod aut provident voluptas autem voluptas", "id": 9, "title": "nesciunt iure omnis dolorem tempora et accusantium", "userId": 1, } `; exports[`HTTP => OpenAPI origin basic bypass caching > 3.3: GET @dev/test-basic-openapi/get_post?postId=9 1`] = ` { "body": "consectetur animi nesciunt iure dolore enim quia ad veniam autem ut quam aut nobis et est aut quod aut provident voluptas autem voluptas", "id": 9, "title": "nesciunt iure omnis dolorem tempora et accusantium", "userId": 1, } `; exports[`HTTP => OpenAPI origin basic bypass caching > 3.4: GET @dev/test-basic-openapi/get_post?postId=9 1`] = ` { "body": "consectetur animi nesciunt iure dolore enim quia ad veniam autem ut quam aut nobis et est aut quod aut provident voluptas autem voluptas", "id": 9, "title": "nesciunt iure omnis dolorem tempora et accusantium", "userId": 1, } `; exports[`HTTP => OpenAPI origin basic getPost errors > 1.8: POST @dev/test-basic-openapi/getPost 1`] = ` { "body": "quia et suscipit suscipit recusandae consequuntur expedita et cum reprehenderit molestiae ut ut quas totam nostrum rerum est autem sunt rem eveniet architecto", "id": 1, "title": "sunt aut facere repellat provident occaecati excepturi optio reprehenderit", "userId": 1, } `; exports[`HTTP => OpenAPI origin basic getPost errors > 1.9: GET @dev/test-basic-openapi/getPost 1`] = ` { "body": "quia et suscipit suscipit recusandae consequuntur expedita et cum reprehenderit molestiae ut ut quas totam nostrum rerum est autem sunt rem eveniet architecto", "id": 1, "title": "sunt aut facere repellat provident occaecati excepturi optio reprehenderit", "userId": 1, } `; exports[`HTTP => OpenAPI origin basic getPost success > 0.0: POST @dev/test-basic-openapi/getPost 1`] = ` { "body": "quia et suscipit suscipit recusandae consequuntur expedita et cum reprehenderit molestiae ut ut quas totam nostrum rerum est autem sunt rem eveniet architecto", "id": 1, "title": "sunt aut facere repellat provident occaecati excepturi optio reprehenderit", "userId": 1, } `; exports[`HTTP => OpenAPI origin basic getPost success > 0.1: POST @dev/test-basic-openapi@latest/getPost 1`] = ` { "body": "quia et suscipit suscipit recusandae consequuntur expedita et cum reprehenderit molestiae ut ut quas totam nostrum rerum est autem sunt rem eveniet architecto", "id": 1, "title": "sunt aut facere repellat provident occaecati excepturi optio reprehenderit", "userId": 1, } `; exports[`HTTP => OpenAPI origin basic getPost success > 0.2: GET @dev/test-basic-openapi/getPost 1`] = ` { "body": "quia et suscipit suscipit recusandae consequuntur expedita et cum reprehenderit molestiae ut ut quas totam nostrum rerum est autem sunt rem eveniet architecto", "id": 1, "title": "sunt aut facere repellat provident occaecati excepturi optio reprehenderit", "userId": 1, } `; exports[`HTTP => OpenAPI origin basic getPost success > 0.3: GET @dev/test-basic-openapi/getPost?postId=1 1`] = ` { "body": "quia et suscipit suscipit recusandae consequuntur expedita et cum reprehenderit molestiae ut ut quas totam nostrum rerum est autem sunt rem eveniet architecto", "id": 1, "title": "sunt aut facere repellat provident occaecati excepturi optio reprehenderit", "userId": 1, } `; exports[`HTTP => OpenAPI origin basic getPost success > 0.4: GET @dev/test-basic-openapi/get_post?postId=1 1`] = ` { "body": "quia et suscipit suscipit recusandae consequuntur expedita et cum reprehenderit molestiae ut ut quas totam nostrum rerum est autem sunt rem eveniet architecto", "id": 1, "title": "sunt aut facere repellat provident occaecati excepturi optio reprehenderit", "userId": 1, } `; exports[`HTTP => OpenAPI origin basic getPost success > 0.5: GET @dev/test-basic-openapi/getPost 1`] = ` { "body": "quia et suscipit suscipit recusandae consequuntur expedita et cum reprehenderit molestiae ut ut quas totam nostrum rerum est autem sunt rem eveniet architecto", "id": 1, "title": "sunt aut facere repellat provident occaecati excepturi optio reprehenderit", "userId": 1, } `; exports[`HTTP => OpenAPI origin default bypass caching > 2.0: GET @dev/test-everything-openapi/echo 1`] = ` { "postId": "9", } `; exports[`HTTP => OpenAPI origin everything "echo" tool with empty body > 10.0: POST @dev/test-everything-openapi/echo 1`] = `{}`; exports[`HTTP => OpenAPI origin everything "pure" tool > 8.0: POST @dev/test-everything-openapi/pure 1`] = ` { "foo": "bar", "nala": "kitten", } `; exports[`HTTP => OpenAPI origin everything "pure" tool > 8.1: POST @dev/test-everything-openapi/pure 1`] = ` { "foo": "bar", "nala": "kitten", } `; ================================================ FILE: apps/e2e/src/__snapshots__/mcp-e2e.test.ts.snap ================================================ // Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html exports[`MCP => OpenAPI origin basic @ dev get_post success > 2.0: @dev/test-basic-openapi@dev/mcp get_post 1`] = ` { "content": [], "isError": false, "structuredContent": { "body": "dignissimos aperiam dolorem qui eum facilis quibusdam animi sint suscipit qui sint possimus cum quaerat magni maiores excepturi ipsam ut commodi dolor voluptatum modi aut vitae", "id": 8, "title": "dolorem dolore est ipsam", "userId": 1, }, } `; exports[`MCP => OpenAPI origin basic @ latest get_post success > 1.0: @dev/test-basic-openapi@latest/mcp get_post 1`] = ` { "content": [], "isError": false, "structuredContent": { "body": "et iusto sed quo iure voluptatem occaecati omnis eligendi aut ad voluptatem doloribus vel accusantium quis pariatur molestiae porro eius odio et labore et velit aut", "id": 3, "title": "ea molestias quasi exercitationem repellat qui ipsa sit aut", "userId": 1, }, } `; exports[`MCP => OpenAPI origin basic bypass caching > 6.0: @dev/test-everything-openapi/mcp echo 1`] = ` { "content": [], "isError": false, "structuredContent": { "postId": 1, }, } `; exports[`MCP => OpenAPI origin basic caching > 7.0: @dev/test-basic-openapi/mcp get_post 1`] = ` { "content": [], "isError": false, "structuredContent": { "body": "quia et suscipit suscipit recusandae consequuntur expedita et cum reprehenderit molestiae ut ut quas totam nostrum rerum est autem sunt rem eveniet architecto", "id": 1, "title": "sunt aut facere repellat provident occaecati excepturi optio reprehenderit", "userId": 1, }, } `; exports[`MCP => OpenAPI origin basic caching > 7.1: @dev/test-basic-openapi/mcp get_post 1`] = ` { "content": [], "isError": false, "structuredContent": { "body": "quia et suscipit suscipit recusandae consequuntur expedita et cum reprehenderit molestiae ut ut quas totam nostrum rerum est autem sunt rem eveniet architecto", "id": 1, "title": "sunt aut facere repellat provident occaecati excepturi optio reprehenderit", "userId": 1, }, } `; exports[`MCP => OpenAPI origin basic caching > 7.2: @dev/test-basic-openapi/mcp get_post 1`] = ` { "content": [], "isError": false, "structuredContent": { "body": "quia et suscipit suscipit recusandae consequuntur expedita et cum reprehenderit molestiae ut ut quas totam nostrum rerum est autem sunt rem eveniet architecto", "id": 1, "title": "sunt aut facere repellat provident occaecati excepturi optio reprehenderit", "userId": 1, }, } `; exports[`MCP => OpenAPI origin basic get_post errors > 4.3: @dev/test-basic-openapi/mcp get_post 1`] = ` { "content": [], "isError": false, "structuredContent": { "body": "dolore placeat quibusdam ea quo vitae magni quis enim qui quis quo nemo aut saepe quidem repellat excepturi ut quia sunt ut sequi eos ea sed quas", "id": 7, "title": "magnam facilis autem", "userId": 1, }, } `; exports[`MCP => OpenAPI origin basic get_post success > 0.0: @dev/test-basic-openapi/mcp get_post 1`] = ` { "content": [], "isError": false, "structuredContent": { "body": "quia et suscipit suscipit recusandae consequuntur expedita et cum reprehenderit molestiae ut ut quas totam nostrum rerum est autem sunt rem eveniet architecto", "id": 1, "title": "sunt aut facere repellat provident occaecati excepturi optio reprehenderit", "userId": 1, }, } `; exports[`MCP => OpenAPI origin basic normalized caching > 8.0: @dev/test-basic-openapi/mcp get_post 1`] = ` { "content": [], "isError": false, "structuredContent": { "body": "quia et suscipit suscipit recusandae consequuntur expedita et cum reprehenderit molestiae ut ut quas totam nostrum rerum est autem sunt rem eveniet architecto", "id": 1, "title": "sunt aut facere repellat provident occaecati excepturi optio reprehenderit", "userId": 1, }, } `; exports[`MCP => OpenAPI origin basic normalized caching > 8.1: @dev/test-basic-openapi/mcp get_post 1`] = ` { "content": [], "isError": false, "structuredContent": { "body": "quia et suscipit suscipit recusandae consequuntur expedita et cum reprehenderit molestiae ut ut quas totam nostrum rerum est autem sunt rem eveniet architecto", "id": 1, "title": "sunt aut facere repellat provident occaecati excepturi optio reprehenderit", "userId": 1, }, } `; exports[`MCP => OpenAPI origin everything "echo" tool with empty body > 13.0: @dev/test-everything-openapi/mcp echo 1`] = ` { "content": [], "isError": false, "structuredContent": {}, } `; exports[`MCP => OpenAPI origin everything "pure" tool > 11.0: @dev/test-everything-openapi/mcp echo 1`] = ` { "content": [], "isError": false, "structuredContent": { "foo": "bar", "nala": "kitten", }, } `; exports[`MCP => OpenAPI origin everything "pure" tool > 11.1: @dev/test-everything-openapi/mcp echo 1`] = ` { "content": [], "isError": false, "structuredContent": { "foo": "bar", "nala": "kitten", }, } `; exports[`MCP => OpenAPI origin everything errors > 5.0: @dev/test-everything-openapi/mcp strict_additional_properties 1`] = ` { "content": [], "isError": false, "structuredContent": { "foo": "bar", }, } `; ================================================ FILE: apps/e2e/src/agentic-examples.ts ================================================ import path from 'node:path' import { fileURLToPath } from 'node:url' const exampleProjectNames = ['search'] const examplesDir = path.join( fileURLToPath(import.meta.url), '..', '..', '..', '..', 'examples', 'mcp-servers' ) export const examples = exampleProjectNames.map((name) => path.join(examplesDir, name) ) ================================================ FILE: apps/e2e/src/deploy-projects.ts ================================================ import type { AgenticApiClient } from '@agentic/platform-api-client' import { loadAgenticConfig } from '@agentic/platform' import pMap from 'p-map' export async function deployProjects( projects: string[], { client, concurrency = 1 }: { client: AgenticApiClient concurrency?: number } ) { const deployments = await pMap( projects, async (project) => { const config = await loadAgenticConfig({ cwd: project }) const deployment = await client.createDeployment(config) console.log(`Deployed ${project} => ${deployment.identifier}`) return deployment }, { concurrency } ) return deployments } ================================================ FILE: apps/e2e/src/dev-client.ts ================================================ import { AgenticApiClient } from '@agentic/platform-api-client' import { env } from './env' export const devClient = new AgenticApiClient({ apiBaseUrl: env.AGENTIC_API_BASE_URL, apiKey: env.AGENTIC_DEV_ACCESS_TOKEN }) ================================================ FILE: apps/e2e/src/dev-fixtures.ts ================================================ import path from 'node:path' import { fileURLToPath } from 'node:url' const fixtureNames = [ // TODO: re-add these // 'basic-raw-free-ts', // 'basic-raw-free-json', // 'pricing-freemium', // 'pricing-pay-as-you-go', // 'pricing-3-plans', // 'pricing-monthly-annual', // 'pricing-custom-0', 'basic-openapi', 'basic-mcp', 'everything-openapi' ] const fixturesDir = path.join( fileURLToPath(import.meta.url), '..', '..', '..', '..', 'fixtures' ) const validFixturesDir = path.join(fixturesDir, 'valid') export const fixtures = fixtureNames.map((name) => path.join(validFixturesDir, name) ) ================================================ FILE: apps/e2e/src/env.ts ================================================ import 'dotenv/config' import { parseZodSchema } from '@agentic/platform-core' import { z } from 'zod' // TODO: derive AGENTIC_API_BASE_URL and AGENTIC_GATEWAY_BASE_URL based on // environment. // TODO: use `@agentic/platform-hono` base env like other services export const envSchema = z.object({ ENVIRONMENT: z .enum(['development', 'test', 'production']) .default('development'), AGENTIC_API_BASE_URL: z.string().url().optional(), AGENTIC_DEV_EMAIL: z.string().email(), AGENTIC_DEV_PASSWORD: z.string().nonempty(), AGENTIC_DEV_ACCESS_TOKEN: z.string().nonempty(), AGENTIC_AGENTIC_EMAIL: z.string().email(), AGENTIC_AGENTIC_PASSWORD: z.string().nonempty(), AGENTIC_GATEWAY_BASE_URL: z .string() .url() .optional() .default('http://localhost:8787') }) // eslint-disable-next-line no-process-env export const env = parseZodSchema(envSchema, process.env, { error: 'Invalid environment variables' }) export const isProd = env.ENVIRONMENT === 'production' ================================================ FILE: apps/e2e/src/http-e2e.test.ts ================================================ import contentType from 'fast-content-type-parse' import defaultKy from 'ky' import pTimes from 'p-times' import { describe, expect, test } from 'vitest' import { env } from './env' import { fixtureSuites } from './http-fixtures' const ky = defaultKy.extend({ prefixUrl: env.AGENTIC_GATEWAY_BASE_URL, // Disable automatic retries for testing. retry: 0, // Some tests expect HTTP errors, so handle them manually instead of throwing. throwHttpErrors: false }) for (const [i, fixtureSuite] of fixtureSuites.entries()) { const { title, fixtures, compareResponseBodies = false, repeat, repeatConcurrency = 1, repeatSuccessCriteria = 'all' } = fixtureSuite const describeFn = fixtureSuite.only ? describe.only : describe describeFn(title, () => { let fixtureResponseBody: any | undefined if (repeat) { expect(repeat).toBeGreaterThan(0) } for (const [j, fixture] of fixtures.entries()) { const method = fixture.request?.method ?? 'GET' const timeout = fixture.timeout ?? 30_000 const { status = 200, contentType: expectedContentType = 'application/json', headers: expectedHeaders, body: expectedBody, validate } = fixture.response ?? {} const snapshot = fixture.response?.snapshot ?? fixtureSuite.snapshot ?? (status >= 200 && status < 300) const debugFixture = !!( fixture.debug ?? fixtureSuite.debug ?? fixture.only ?? fixtureSuite.only ) const fixtureName = `${i}.${j}: ${method} ${fixture.path}` let testFn = (fixture.only ?? fixture.debug) ? test.only : test if (fixtureSuite.sequential) { testFn = testFn.sequential } testFn( fixtureName, { timeout }, // eslint-disable-next-line no-loop-func async () => { const numIterations = repeat ?? 1 let numSuccessCases = 0 await pTimes( numIterations, async (iteration: number) => { const repeatIterationPrefix = repeat ? `[${iteration}/${numIterations}] ` : '' const res = await ky(fixture.path, { timeout, ...fixture.request }) if ( res.status !== status && (res.status >= 500 || status === 200) ) { let body: any try { body = await res.json() } catch {} console.error( `${repeatIterationPrefix}${fixtureName} => UNEXPECTED ERROR ${res.status} (expected ${status}):`, JSON.stringify(body, null, 2) ) } if (repeat) { if (res.status === status) { ++numSuccessCases } else { if (debugFixture) { console.log( `${repeatIterationPrefix}${fixtureName} => ${res.status} (invalid sample; expected ${status})`, { headers: Object.fromEntries(res.headers.entries()) } ) } return } } else { expect(res.status).toBe(status) } const { type } = contentType.safeParse( res.headers.get('content-type') ?? '' ) expect(type).toBe(expectedContentType) let body: any if (type.includes('json')) { try { body = await res.json() } catch (err) { console.error('json error', err) throw err } } else if (type.includes('text')) { body = await res.text() } else { body = await res.arrayBuffer() } if (debugFixture) { console.log( `${repeatIterationPrefix}${fixtureName} => ${res.status}`, body, { headers: Object.fromEntries(res.headers.entries()) } ) } if (expectedBody) { expect(body).toEqual(expectedBody) } if (validate) { await Promise.resolve(validate(body)) } if (snapshot) { expect(body).toMatchSnapshot() } if (expectedHeaders) { for (const [key, value] of Object.entries(expectedHeaders)) { expect(res.headers.get(key)).toBe(value) } } if (compareResponseBodies && status >= 200 && status < 300) { if (!fixtureResponseBody) { fixtureResponseBody = body } else { expect(body).toEqual(fixtureResponseBody) } } }, { concurrency: repeatConcurrency, stopOnError: true } ) if (repeat) { if (repeatSuccessCriteria === 'all') { expect(numSuccessCases).toBe(numIterations) } else if (repeatSuccessCriteria === 'some') { expect(numSuccessCases).toBeGreaterThan(0) } else if (typeof repeatSuccessCriteria === 'function') { await Promise.resolve(repeatSuccessCriteria(numSuccessCases)) } } } ) } }) } ================================================ FILE: apps/e2e/src/http-fixtures.ts ================================================ import { expect } from 'vitest' export type E2ETestFixture = { path: string /** @default 60_000 milliseconds */ timeout?: number /** @default false */ only?: boolean /** @default false */ debug?: boolean request?: { /** @default 'GET' */ method?: 'GET' | 'POST' | 'PUT' | 'DELETE' searchParams?: Record headers?: Record json?: Record body?: any } response?: { /** @default 200 */ status?: number /** @default 'application/json' */ contentType?: string headers?: Record body?: any validate?: (body: any) => void | Promise /** @default true */ snapshot?: boolean } } export type E2ETestFixtureSuite = { title: string fixtures: E2ETestFixture[] /** * Development-only flag that runs exclusively this test suite, ignoring all others. * Uses Vitest's `describe.only()` to focus on a single test suite. * * ⚠️ WARNING: Never commit this as `true` - it will cause CI to skip all other tests. * * @default false */ only?: boolean /** @default false */ sequential?: boolean /** @default false */ compareResponseBodies?: boolean /** @default false */ debug?: boolean /** @default undefined */ snapshot?: boolean /** @default undefined */ repeat?: number /** @default 1 */ repeatConcurrency?: number /** @default 'all' */ repeatSuccessCriteria?: | 'all' | 'some' | ((numRepeatSuccesses: number) => void | Promise) } const now = Date.now() export const fixtureSuites: E2ETestFixtureSuite[] = [ { title: 'HTTP => OpenAPI origin basic getPost success', compareResponseBodies: true, fixtures: [ { path: '@dev/test-basic-openapi/getPost', request: { method: 'POST', json: { postId: 1 } } }, { path: '@dev/test-basic-openapi@latest/getPost', request: { method: 'POST', json: { postId: 1 } } }, { path: '@dev/test-basic-openapi/getPost', request: { searchParams: { // all of these GET requests implicitly test type coercion since // `postId` as a query param will be a string, but the tool expects // an integer. postId: 1 } } }, { path: '@dev/test-basic-openapi/getPost?postId=1' }, { path: '@dev/test-basic-openapi/get_post?postId=1' }, { path: '@dev/test-basic-openapi/getPost', request: { searchParams: { postId: 1 } } } ] }, { title: 'HTTP => OpenAPI origin basic getPost errors', fixtures: [ { path: '@dev/test-basic-openapi/getPost', request: { method: 'GET' }, response: { // Missing `postId` parameter. status: 400 } }, { path: '@dev/test-basic-openapi/getPost?postId=foo', response: { status: 400 } }, { path: '@dev/test-basic-openapi@00000000/getPost', response: { // deployment hash 00000000 doesn't exist status: 404 } }, { path: '@dev/test-basic-openapi/getPost', request: { method: 'PUT', json: { postId: 1 } }, response: { // PUT is not a valid method (must be POST) status: 405 } }, { path: '@dev/test-basic-openapi@latest/get_kittens?postId=1', response: { status: 404 } }, { path: '@dev/test-basic-openapi/getPost', request: { searchParams: { // invalid `postId` field type postId: 'not-a-number' } }, response: { status: 400 } }, { path: '@dev/test-basic-openapi/getPost', request: { method: 'POST', json: { // invalid `postId` field type postId: 'not-a-number' } }, response: { status: 400 } }, { path: '@dev/test-basic-openapi/getPost', request: { method: 'POST', json: { // missing required `postId` field } }, response: { status: 400 } }, { path: '@dev/test-basic-openapi/getPost', request: { method: 'POST', json: { postId: 1, // additional json body params are allowed by default foo: 'bar' } }, response: { status: 200 } }, { path: '@dev/test-basic-openapi/getPost', request: { searchParams: { postId: 1, // additional search params should allowed by default foo: 'bar' } }, response: { status: 200 } }, { path: '@dev/test-everything-openapi/strict_additional_properties', request: { method: 'POST', json: { foo: 'bar', // additional json body params should throw an error if the tool // config has `additionalProperties: false` extra: 'nala' } }, response: { status: 400 } }, { path: '@dev/test-everything-openapi/strict_additional_properties', request: { method: 'GET', searchParams: { foo: 'bar', // additional search params should throw an error if the tool // config has `additionalProperties: false` extra: 'nala' } }, response: { status: 400 } } ] }, { title: 'HTTP => OpenAPI origin default bypass caching', compareResponseBodies: true, fixtures: [ { // ensure we bypass the cache for requests for tools which do not have // a custom `pure` or `cacheControl` set in their tool config. path: '@dev/test-everything-openapi/echo', request: { searchParams: { postId: 9 } }, response: { headers: { 'cf-cache-status': 'BYPASS' } } } ] }, { title: 'HTTP => OpenAPI origin basic bypass caching', compareResponseBodies: true, fixtures: [ { // ensure we bypass the cache for requests with `pragma: no-cache` path: '@dev/test-basic-openapi/getPost', request: { headers: { pragma: 'no-cache' }, searchParams: { postId: 9 } }, response: { headers: { 'cf-cache-status': 'BYPASS' } } }, { // ensure we bypass the cache for requests with `cache-control: no-cache` path: '@dev/test-basic-openapi/getPost?postId=9', request: { headers: { 'cache-control': 'no-cache' } }, response: { headers: { 'cf-cache-status': 'BYPASS' } } }, { // ensure we bypass the cache for requests with `cache-control: no-store` path: '@dev/test-basic-openapi/get_post?postId=9', request: { headers: { 'cache-control': 'no-store' } }, response: { headers: { 'cf-cache-status': 'BYPASS' } } }, { path: '@dev/test-basic-openapi/get_post?postId=9', request: { headers: { 'cache-control': 'max-age=0, must-revalidate, no-cache' } }, response: { headers: { 'cf-cache-status': 'BYPASS' } } }, { path: '@dev/test-basic-openapi/get_post?postId=9', request: { headers: { 'cache-control': 'private, max-age=3600, must-revalidate' } }, response: { headers: { 'cf-cache-status': 'BYPASS' } } } ] }, { title: 'HTTP => OpenAPI origin basic GET caching', compareResponseBodies: true, sequential: true, fixtures: [ { // first request to ensure the cache is populated path: '@dev/test-basic-openapi/getPost', request: { headers: { 'cache-control': 'public, max-age=3600, s-maxage=3600, stale-while-revalidate=3600' }, searchParams: { postId: 13 } } }, { // second request should hit the cache path: '@dev/test-basic-openapi/getPost?postId=13', request: { headers: { 'cache-control': 'public, max-age=3600, s-maxage=3600, stale-while-revalidate=3600' } }, response: { headers: { 'cf-cache-status': 'HIT' } } }, { // normalized request with different path should also hit the cache path: '@dev/test-basic-openapi/get_post?postId=13', request: { headers: { 'cache-control': 'public, max-age=3600, s-maxage=3600, stale-while-revalidate=3600' } }, response: { headers: { 'cf-cache-status': 'HIT' } } } ] }, { title: 'HTTP => OpenAPI origin basic POST caching', compareResponseBodies: true, sequential: true, fixtures: [ { // first request to ensure the cache is populated path: '@dev/test-basic-openapi/get_post', request: { method: 'POST', json: { postId: 13 } } }, { // second request should hit the cache path: '@dev/test-basic-openapi/get_post', request: { method: 'POST', json: { postId: 13 } }, response: { headers: { 'cf-cache-status': 'HIT' } } } ] }, { title: 'HTTP => MCP origin basic "add" tool', compareResponseBodies: true, fixtures: [ { path: '@dev/test-basic-mcp/add', request: { method: 'POST', json: { a: 22, b: 20 } }, response: { body: [{ type: 'text', text: '42' }] } }, { path: '@dev/test-basic-mcp/add', request: { searchParams: { a: 22, b: 20 } }, response: { body: [{ type: 'text', text: '42' }] } } ] }, { title: 'HTTP => MCP origin basic "echo" tool', snapshot: false, fixtures: [ { path: '@dev/test-basic-mcp/echo', request: { method: 'POST', json: { nala: 'kitten', num: 123, now } }, response: { body: [ { type: 'text', text: JSON.stringify({ nala: 'kitten', num: 123, now }) } ] } }, { path: '@dev/test-basic-mcp/echo', request: { searchParams: { nala: 'kitten', num: 123, now } }, response: { body: [ { type: 'text', text: JSON.stringify({ nala: 'kitten', num: '123', now: `${now}` }) } ] } } ] }, { title: 'HTTP => OpenAPI origin everything "pure" tool', sequential: true, compareResponseBodies: true, fixtures: [ { path: '@dev/test-everything-openapi/pure', request: { method: 'POST', json: { nala: 'kitten', foo: 'bar' } }, response: { headers: { 'cache-control': 'public, max-age=31560000, s-maxage=31560000, stale-while-revalidate=3600' }, body: { nala: 'kitten', foo: 'bar' } } }, { // second request should hit the cache path: '@dev/test-everything-openapi/pure', request: { method: 'POST', json: { nala: 'kitten', foo: 'bar' } }, response: { headers: { 'cf-cache-status': 'HIT', 'cache-control': 'public, max-age=31560000, s-maxage=31560000, stale-while-revalidate=3600' }, body: { nala: 'kitten', foo: 'bar' } } } ] }, { title: 'HTTP => OpenAPI origin everything "disabled_tool" tool', fixtures: [ { path: '@dev/test-everything-openapi/disabled_tool', request: { method: 'POST' }, response: { // 400 because the request body is missing status: 400 } }, { path: '@dev/test-everything-openapi/disabled_tool', request: { method: 'POST', json: {} }, response: { // 404 because the tool is disabled which means its hidden status: 404 } } ] }, { title: 'HTTP => OpenAPI origin everything "echo" tool with empty body', compareResponseBodies: true, fixtures: [ { path: '@dev/test-everything-openapi/echo', request: { method: 'POST', json: {} }, response: { body: {} } } ] }, { title: 'HTTP => OpenAPI origin everything "unpure_marked_pure" tool', compareResponseBodies: true, snapshot: false, fixtures: [ { path: '@dev/test-everything-openapi/unpure_marked_pure', request: { method: 'POST', json: { nala: 'cat' } }, response: { validate: (body) => { expect(body?.nala).toEqual('cat') expect(typeof body.now).toBe('number') expect(body.now).toBeGreaterThan(0) } } }, { // compareResponseBodies should result in the same cached response body, // even though the origin would return a different `now` value if it // weren't marked `pure`. path: '@dev/test-everything-openapi/unpure_marked_pure', request: { method: 'POST', json: { nala: 'cat' } }, response: { headers: { 'cf-cache-status': 'HIT' } } } ] }, { title: 'HTTP => OpenAPI origin everything "echo_headers" tool', snapshot: false, fixtures: [ { path: '@dev/test-everything-openapi/echo_headers', response: { validate: (body) => { expect(body['x-agentic-proxy-secret']).toBeTruthy() expect(body['x-agentic-proxy-secret']?.length).toBe(64) expect(body['x-agentic-deployment-id']).toBeTruthy() expect( body['x-agentic-deployment-id']?.startsWith('depl_') ).toBeTruthy() expect(body['x-agentic-deployment-identifier']).toBeTruthy() expect(body['x-agentic-is-customer-subscription-active']).toEqual( 'false' ) expect(body['x-agentic-user-id']).toBeUndefined() expect(body['x-agentic-customer-id']).toBeUndefined() } } } ] }, { title: 'HTTP => OpenAPI origin everything "custom_rate_limit_tool" (strict mode)', repeat: 5, repeatSuccessCriteria: (numRepeatSuccesses) => { expect( numRepeatSuccesses, 'should have at least three 429 responses out of 5 requests with a strict rate limit of 2 requests per 30s' ).toBeGreaterThanOrEqual(3) }, fixtures: [ { path: '@dev/test-everything-openapi/custom_rate_limit_tool', response: { status: 429, headers: { 'ratelimit-policy': '2;w=30', 'ratelimit-limit': '2' } } } ] }, { title: 'HTTP => OpenAPI origin everything "custom_rate_limit_approximate_tool" (approximate mode)', repeat: 16, repeatConcurrency: 8, repeatSuccessCriteria: (numRepeatSuccesses) => { expect( numRepeatSuccesses, 'should have at least one 429 response' ).toBeGreaterThan(0) }, fixtures: [ { path: '@dev/test-everything-openapi/custom_rate_limit_approximate_tool', response: { status: 429, headers: { 'ratelimit-policy': '2;w=30', 'ratelimit-limit': '2' } } } ] } // TODO // { // title: 'HTTP => Production MCP origin "search" tool', // // NOTE: this one actually hits a production service and costs a small // // amount of $ per request. // fixtures: [ // { // path: '@agentic/search/search', // request: { // method: 'POST', // json: { // query: 'latest ai news' // } // }, // response: { // snapshot: false // } // } // ] // } ] ================================================ FILE: apps/e2e/src/mcp-e2e.test.ts ================================================ import { pick } from '@agentic/platform-core' import { Client as McpClient } from '@modelcontextprotocol/sdk/client/index.js' import { StreamableHTTPClientTransport } from '@modelcontextprotocol/sdk/client/streamableHttp.js' import pTimes from 'p-times' import { afterAll, beforeAll, describe, expect, test } from 'vitest' import { env } from './env' import { fixtureSuites } from './mcp-fixtures' for (const [i, fixtureSuite] of fixtureSuites.entries()) { const { title, fixtures, compareResponseBodies = false, repeat, repeatConcurrency = 1, repeatSuccessCriteria = 'all' } = fixtureSuite const describeFn = fixtureSuite.only ? describe.only : describe describeFn(title, () => { let fixtureResult: any | undefined let client: McpClient if (repeat) { expect(repeat).toBeGreaterThan(0) } beforeAll(async () => { client = new McpClient({ name: fixtureSuite.path, version: '0.0.0' }) // TODO: add origin requestInit headers const transport = new StreamableHTTPClientTransport( new URL(fixtureSuite.path, env.AGENTIC_GATEWAY_BASE_URL) ) await client.connect(transport) const { tools } = await client.listTools() // Ensure all tools used by the test fixtures in this suite are available. // Ignore test fixtures which are expected to error. for (const [_, fixture] of fixtures.entries()) { const { isError } = fixture.response ?? {} if (!isError) { const toolName = fixture.request.name expect(tools.map((t) => t.name)).toContain(toolName) } } }, 120_000) afterAll(async () => { await client.close() }) for (const [j, fixture] of fixtures.entries()) { const { isError, result: expectedResult, content: expectedContent, structuredContent: expectedStructuredContent, _meta: expectedMeta, _agenticMeta: expectedAgenticMeta, _agenticMetaHeaders: expectedAgenticMetaHeaders, validate } = fixture.response ?? {} const toolName = fixture.request.name const expectedSnapshot = fixture.response?.snapshot ?? fixtureSuite.snapshot ?? false const expectedStableSnapshot = fixture.response?.stableSnapshot ?? fixture.response?.snapshot ?? fixtureSuite.stableSnapshot ?? fixtureSuite.snapshot ?? !isError const debugFixture = !!( fixture.debug ?? fixtureSuite.debug ?? fixture.only ?? fixtureSuite.only ) const fixtureName = `${i}.${j}: ${fixtureSuite.path} ${toolName}` let testFn = (fixture.only ?? fixture.debug) ? test.only : test if (fixtureSuite.sequential) { testFn = testFn.sequential } testFn( fixtureName, { timeout: fixture.timeout ?? 60_000 }, // eslint-disable-next-line no-loop-func async () => { const numIterations = repeat ?? 1 let numSuccessCases = 0 await pTimes( numIterations, async (iteration: number) => { const repeatIterationPrefix = repeat ? `[${iteration}/${numIterations}] ` : '' const result = await client.callTool({ name: toolName, arguments: fixture.request.args, _meta: fixture.request._meta }) if (repeat) { if (result.isError === isError) { ++numSuccessCases } else { if (debugFixture) { console.log( `${repeatIterationPrefix}${fixtureName} => (invalid sample; expected ${result.isError ? 'error' : 'no error'})`, JSON.stringify(result, null, 2) ) } return } } if (debugFixture) { console.log( `${repeatIterationPrefix}${fixtureName} =>`, JSON.stringify(result, null, 2) ) } if (isError) { expect(result.isError).toBeTruthy() } else { expect(result.isError).toBeFalsy() } if (expectedResult) { expect(result).toEqual(expectedResult) } if (expectedContent) { expect(result.content).toEqual(expectedContent) } if (expectedStructuredContent) { expect(result.structuredContent).toEqual( expectedStructuredContent ) } if (expectedMeta) { expect(result._meta).toBeDefined() expect(typeof result._meta).toEqual('object') expect(!Array.isArray(result._meta)).toBeTruthy() for (const [key, value] of Object.entries(expectedMeta)) { expect(result._meta![key]).toEqual(value) } } if (expectedAgenticMeta) { expect(result._meta).toBeDefined() expect(result._meta?.agentic).toBeDefined() expect(typeof result._meta?.agentic).toEqual('object') expect(!Array.isArray(result._meta?.agentic)).toBeTruthy() for (const [key, value] of Object.entries( expectedAgenticMeta )) { expect((result._meta!.agentic as any)[key]).toEqual(value) } } if (expectedAgenticMetaHeaders) { expect(result._meta).toBeDefined() expect(result._meta?.agentic).toBeDefined() expect(typeof result._meta?.agentic).toEqual('object') expect(!Array.isArray(result._meta?.agentic)).toBeTruthy() expect(typeof (result._meta?.agentic as any)?.headers).toEqual( 'object' ) expect( !Array.isArray((result._meta?.agentic as any)?.headers) ).toBeTruthy() for (const [key, value] of Object.entries( expectedAgenticMetaHeaders )) { expect((result._meta!.agentic as any).headers[key]).toEqual( value ) } } if (expectedSnapshot) { expect(result).toMatchSnapshot() } const stableResult = pick( result, 'content', 'structuredContent', 'isError' ) if (expectedStableSnapshot) { expect(stableResult).toMatchSnapshot() } if (validate) { await Promise.resolve(validate(result)) } if (compareResponseBodies && !isError) { if (!fixtureResult) { fixtureResult = stableResult } else { expect(stableResult).toEqual(fixtureResult) } } }, { concurrency: repeatConcurrency, stopOnError: true } ) if (repeat) { if (repeatSuccessCriteria === 'all') { expect(numSuccessCases).toBe(numIterations) } else if (repeatSuccessCriteria === 'some') { expect(numSuccessCases).toBeGreaterThan(0) } else if (typeof repeatSuccessCriteria === 'function') { await Promise.resolve(repeatSuccessCriteria(numSuccessCases)) } } } ) } }) } ================================================ FILE: apps/e2e/src/mcp-fixtures.ts ================================================ import { expect } from 'vitest' export type MCPE2ETestFixture = { /** @default 60_000 milliseconds */ timeout?: number /** @default false */ only?: boolean /** @default false */ debug?: boolean request: { name: string args: Record _meta?: Record } response?: { result?: any /** @default false */ isError?: boolean content?: Array> structuredContent?: any _meta?: Record _agenticMeta?: Record _agenticMetaHeaders?: Record validate?: (result: any) => void | Promise /** @default undefined */ snapshot?: boolean /** @default true */ stableSnapshot?: boolean } } export type MCPE2ETestFixtureSuite = { title: string path: string fixtures: MCPE2ETestFixture[] /** @default false */ only?: boolean /** @default false */ sequential?: boolean /** @default false */ compareResponseBodies?: boolean /** @default false */ debug?: boolean /** * Not used by default because the result `_meta.agentic` contains some * metadata which may not be stable across test runs such as `cacheStatus` * and `headers`. * * @default false */ snapshot?: boolean /** @default undefined */ stableSnapshot?: boolean /** @default undefined */ repeat?: number /** @default 1 */ repeatConcurrency?: number /** @default 'all' */ repeatSuccessCriteria?: | 'all' | 'some' | ((numRepeatSuccesses: number) => void | Promise) } const now = Date.now() export const fixtureSuites: MCPE2ETestFixtureSuite[] = [ { title: 'MCP => OpenAPI origin basic get_post success', path: '@dev/test-basic-openapi/mcp', fixtures: [ { request: { name: 'get_post', args: { postId: 1 } } } ] }, { title: 'MCP => OpenAPI origin basic @ latest get_post success ', path: '@dev/test-basic-openapi@latest/mcp', fixtures: [ { request: { name: 'get_post', args: { postId: 3 } } } ] }, { title: 'MCP => OpenAPI origin basic @ dev get_post success ', path: '@dev/test-basic-openapi@dev/mcp', fixtures: [ { request: { name: 'get_post', args: { postId: 8 } } } ] }, { title: 'MCP => MCP origin basic "echo" tool call success', path: '@dev/test-basic-mcp/mcp', stableSnapshot: false, fixtures: [ { request: { name: 'echo', args: { nala: 'kitten', num: 123, now } }, response: { content: [ { type: 'text', text: JSON.stringify({ nala: 'kitten', num: 123, now }) } ], _agenticMeta: { cacheStatus: 'DYNAMIC' } } }, { request: { name: 'echo', args: { nala: 'kitten', num: 123, now: `${now}` } }, response: { content: [ { type: 'text', text: JSON.stringify({ nala: 'kitten', num: 123, now: `${now}` }) } ], _agenticMeta: { cacheStatus: 'DYNAMIC' } } } ] }, { title: 'MCP => OpenAPI origin basic get_post errors', path: '@dev/test-basic-openapi/mcp', fixtures: [ { request: { name: 'get_post', args: { // Missing required `postId` parameter nala: 'kitten', num: 123, now } }, response: { isError: true, _agenticMeta: { status: 400 } } }, { request: { name: 'get_post', args: { // invalid `postId` parameter postId: 'not-a-number' } }, response: { isError: true, _agenticMeta: { status: 400 } } }, { request: { name: 'get_kittens', args: { postId: 7 } }, response: { isError: true, _agenticMeta: { // 'get_kittens' tool doesn't exist status: 404, toolName: 'get_kittens' } } }, { request: { name: 'get_post', args: { postId: 7, // additional json body params are allowed by default foo: 'bar' } }, response: { isError: false } } ] }, { title: 'MCP => OpenAPI origin everything errors', path: '@dev/test-everything-openapi/mcp', fixtures: [ { request: { name: 'strict_additional_properties', args: { foo: 'bar' } }, response: { isError: false } }, { request: { name: 'strict_additional_properties', args: { foo: 'bar', // additional params should throw an error if the tool // config has `additionalProperties: false` extra: 'nala' } }, response: { isError: true, _agenticMeta: { status: 400 } } } ] }, { title: 'MCP => OpenAPI origin basic bypass caching', path: '@dev/test-everything-openapi/mcp', fixtures: [ { // ensure we bypass the cache for requests for tools which do not have // a custom `pure` or `cacheControl` set in their tool config. request: { name: 'echo', args: { postId: 1 } }, response: { isError: false, _agenticMeta: { cacheStatus: 'BYPASS' } } } ] }, { title: 'MCP => OpenAPI origin basic caching', path: '@dev/test-basic-openapi/mcp', fixtures: [ { request: { name: 'get_post', args: { postId: 1 } }, response: { isError: false } }, { request: { name: 'get_post', args: { postId: 1 } }, response: { isError: false, _agenticMeta: { // second request should hit the cache cacheStatus: 'HIT' } } }, { request: { name: 'get_post', args: { postId: 1 }, // disable caching via a custom metadata cache-control header _meta: { agentic: { headers: { 'cache-control': 'no-store' } } } }, response: { isError: false, _agenticMeta: { cacheStatus: 'BYPASS' } } } ] }, { title: 'MCP => OpenAPI origin basic normalized caching', path: '@dev/test-basic-openapi/mcp', fixtures: [ { request: { name: 'get_post', args: { postId: 1, foo: true, nala: 'kitten' } }, response: { isError: false } }, { request: { name: 'get_post', args: { foo: true, postId: 1, nala: 'kitten' } }, response: { isError: false, _agenticMeta: { // second request should hit the cache even though the args are in a // different order cacheStatus: 'HIT' } } } ] }, { title: 'MCP => MCP origin basic "add" tool call success', path: '@dev/test-basic-mcp/mcp', stableSnapshot: false, fixtures: [ { request: { name: 'add', args: { a: 13, b: 49 } }, response: { isError: false, content: [{ type: 'text', text: '62' }] } } ] }, { title: 'MCP => MCP origin basic "echo" tool', path: '@dev/test-basic-mcp/mcp', stableSnapshot: false, fixtures: [ { request: { name: 'echo', args: { nala: 'kitten', num: 123, now } }, response: { isError: false, content: [ { type: 'text', text: JSON.stringify({ nala: 'kitten', num: 123, now }) } ] } } ] }, { title: 'MCP => OpenAPI origin everything "pure" tool', path: '@dev/test-everything-openapi/mcp', compareResponseBodies: true, fixtures: [ { request: { name: 'echo', args: { nala: 'kitten', foo: 'bar' }, _meta: { agentic: { headers: { 'cache-control': 'public, max-age=31560000, s-maxage=31560000, stale-while-revalidate=3600' } } } }, response: { isError: false, structuredContent: { nala: 'kitten', foo: 'bar' } } }, { // second request should hit the cache request: { name: 'echo', args: { nala: 'kitten', foo: 'bar' }, _meta: { agentic: { headers: { 'cache-control': 'public, max-age=31560000, s-maxage=31560000, stale-while-revalidate=3600' } } } }, response: { isError: false, structuredContent: { nala: 'kitten', foo: 'bar' }, _agenticMeta: { cacheStatus: 'HIT' } } } ] }, { title: 'MCP => OpenAPI origin everything "disabled_tool" tool', path: '@dev/test-everything-openapi/mcp', fixtures: [ { request: { name: 'disabled_tool', args: { foo: 'bar' } }, response: { isError: true, _agenticMeta: { status: 404, toolName: 'disabled_tool' } } } ] }, { title: 'MCP => OpenAPI origin everything "echo" tool with empty body', path: '@dev/test-everything-openapi/mcp', fixtures: [ { request: { name: 'echo', args: {} }, response: { isError: false, structuredContent: {} } } ] }, { title: 'MCP => OpenAPI origin everything "unpure_marked_pure" tool', path: '@dev/test-everything-openapi/mcp', compareResponseBodies: true, stableSnapshot: false, fixtures: [ { request: { name: 'unpure_marked_pure', args: { nala: 'cat' } }, response: { isError: false, validate: (result) => { const body = result.structuredContent expect(body?.nala).toEqual('cat') expect(typeof body.now).toBe('number') expect(body.now).toBeGreaterThan(0) } } }, { // compareResponseBodies should result in the same cached response body, // even though the origin would return a different `now` value if it // weren't marked `pure`. request: { name: 'unpure_marked_pure', args: { nala: 'cat' } }, response: { isError: false, _agenticMeta: { cacheStatus: 'HIT' } } } ] }, { title: 'MCP => OpenAPI origin everything "echo_headers" tool', path: '@dev/test-everything-openapi/mcp', stableSnapshot: false, fixtures: [ { request: { name: 'echo_headers', args: {} }, response: { validate: (result) => { expect( result.structuredContent['x-agentic-proxy-secret'] ).toBeTruthy() expect( result.structuredContent['x-agentic-proxy-secret']?.length ).toBe(64) expect( result.structuredContent['x-agentic-deployment-id'] ).toBeTruthy() expect( result.structuredContent['x-agentic-deployment-id']?.startsWith( 'depl_' ) ).toBeTruthy() expect( result.structuredContent['x-agentic-deployment-identifier'] ).toBeTruthy() expect( result.structuredContent[ 'x-agentic-is-customer-subscription-active' ] ).toEqual('false') expect( result.structuredContent['x-agentic-user-id'] ).toBeUndefined() expect( result.structuredContent['x-agentic-customer-id'] ).toBeUndefined() } } } ] }, { title: 'MCP => OpenAPI origin everything "custom_rate_limit_tool" (strict mode)', path: '@dev/test-everything-openapi/mcp', repeat: 5, repeatSuccessCriteria: (numRepeatSuccesses) => { expect( numRepeatSuccesses, 'should have at least three 429 responses out of 5 requests with a strict rate limit of 2 requests per 30s' ).toBeGreaterThanOrEqual(3) }, fixtures: [ { request: { name: 'custom_rate_limit_tool', args: {} }, response: { isError: true, _agenticMeta: { status: 429 }, _agenticMetaHeaders: { 'ratelimit-policy': '2;w=30', 'ratelimit-limit': '2' } } } ] }, { title: 'MCP => OpenAPI origin everything "custom_rate_limit_approximate_tool" (approximate mode)', path: '@dev/test-everything-openapi/mcp', repeat: 16, repeatConcurrency: 8, repeatSuccessCriteria: (numRepeatSuccesses) => { expect( numRepeatSuccesses, 'should have at least one 429 response' ).toBeGreaterThan(0) }, fixtures: [ { request: { name: 'custom_rate_limit_approximate_tool', args: {} }, response: { isError: true, _agenticMeta: { status: 429 }, _agenticMetaHeaders: { 'ratelimit-policy': '2;w=30', 'ratelimit-limit': '2' } } } ] } ] ================================================ FILE: apps/e2e/src/publish-deployments.ts ================================================ import type { AgenticApiClient } from '@agentic/platform-api-client' import type { Deployment } from '@agentic/platform-types' import { assert } from '@agentic/platform-core' import pMap from 'p-map' import semver from 'semver' export async function publishDeployments( deployments: Deployment[], { client, concurrency = 1 }: { client: AgenticApiClient concurrency?: number } ) { const publishedDeployments = await pMap( deployments, async (deployment) => { const project = await client.getProject({ projectId: deployment.projectId, populate: ['lastDeployment'] }) const baseVersion = project.lastPublishedDeploymentVersion || '0.0.0' const version = semver.inc(baseVersion, 'patch') assert(version, `Failed to increment deployment version "${baseVersion}"`) const publishedDeployment = await client.publishDeployment( { version }, { deploymentId: deployment.id } ) console.log(`Published ${deployment.identifier} => ${version}`) return publishedDeployment }, { concurrency } ) return publishedDeployments } ================================================ FILE: apps/e2e/tsconfig.json ================================================ { "extends": "@fisch0920/config/tsconfig-node", "include": ["src", "bin", "*.config.ts"], "exclude": ["node_modules", "dist"] } ================================================ FILE: apps/e2e/vitest.config.ts ================================================ import { defineConfig } from 'vitest/config' export default defineConfig({ test: { environment: 'node', globals: true, watch: false, restoreMocks: true } }) ================================================ FILE: apps/gateway/.dev.vars.example ================================================ ENVIRONMENT= SENTRY_DSN= AGENTIC_API_BASE_URL= AGENTIC_API_KEY= STRIPE_SECRET_KEY= ================================================ FILE: apps/gateway/package.json ================================================ { "name": "gateway", "private": true, "version": "8.4.4", "description": "Internal Agentic platform API gateway.", "author": "Travis Fischer ", "license": "AGPL-3.0", "repository": { "type": "git", "url": "git+https://github.com/transitive-bullshit/agentic.git", "directory": "apps/gateway" }, "type": "module", "source": "./src/worker.ts", "scripts": { "dev": "wrangler dev", "deploy": "wrangler deploy --env production --outdir dist --upload-source-maps --var SENTRY_RELEASE:$(sentry-cli releases propose-version)", "deploy:cf": "wrangler deploy --env production --outdir dist --upload-source-maps", "cf-clear-cache": "del .wrangler", "clean": "del dist", "test": "run-s test:*", "test:typecheck": "tsc --noEmit", "test:unit": "vitest run", "sentry:sourcemaps": "_SENTRY_RELEASE=$(sentry-cli releases propose-version) && sentry-cli releases new $_SENTRY_RELEASE --org=agentic-platform --project=gateway && sentry-cli sourcemaps upload --org=agentic-platform --project=gateway --release=$_SENTRY_RELEASE --strip-prefix 'dist/..' dist", "postdeploy": "pnpm sentry:sourcemaps" }, "dependencies": { "@agentic/json-schema": "workspace:*", "@agentic/platform-api-client": "workspace:*", "@agentic/platform-core": "workspace:*", "@agentic/platform-hono": "workspace:*", "@agentic/platform-types": "workspace:*", "@agentic/platform-validators": "workspace:*", "@hono/zod-validator": "catalog:", "@modelcontextprotocol/sdk": "catalog:", "@sentry/cloudflare": "catalog:", "agents": "catalog:", "fast-content-type-parse": "catalog:", "hono": "catalog:", "ky": "catalog:", "plur": "catalog:", "sort-keys": "catalog:", "stripe": "catalog:", "type-fest": "catalog:" }, "devDependencies": { "@cloudflare/workers-types": "catalog:", "@edge-runtime/vm": "catalog:", "@sentry/cli": "catalog:", "wrangler": "catalog:" } } ================================================ FILE: apps/gateway/src/app.ts ================================================ import { assert } from '@agentic/platform-core' import { applyRateLimitHeaders, cors, errorHandler, init, responseTime, sentry } from '@agentic/platform-hono' import { Hono } from 'hono' import type { GatewayHonoEnv, ResolvedHttpEdgeRequest, ResolvedOriginToolCallResult } from './lib/types' import { createAgenticClient } from './lib/agentic-client' import { createHttpResponseFromMcpToolCallResponse } from './lib/create-http-response-from-mcp-tool-call-response' import { recordToolCallUsage } from './lib/record-tool-call-usage' import { resolveEdgeRequest } from './lib/resolve-edge-request' import { resolveHttpEdgeRequest } from './lib/resolve-http-edge-request' import { resolveMcpEdgeRequest } from './lib/resolve-mcp-edge-request' import { resolveOriginToolCall } from './lib/resolve-origin-tool-call' import { isRequestPubliclyCacheable } from './lib/utils' import { DurableMcpServer } from './worker' export const app = new Hono() app.onError(errorHandler) app.use(sentry()) // TODO: Compression is causing a weird bug on dev even for simple responses. // I think it's because wrangler is changing the response to be streamed // with `transfer-encoding: chunked`, which is not compatible with // `hono/compress`. // app.use(compress()) app.use( cors({ origin: '*', allowHeaders: ['Content-Type', 'Authorization', 'mcp-session-id'], allowMethods: ['GET', 'POST', 'DELETE', 'OPTIONS'], exposeHeaders: ['Content-Length', 'mcp-session-id'], maxAge: 86_400, credentials: true }) ) app.use(init) // Wrangler does this for us. TODO: Does this happen on prod? // app.use(accessLogger) app.use(responseTime) app.all(async (ctx) => { const waitUntil = ctx.executionCtx.waitUntil.bind(ctx.executionCtx) const isCachingEnabled = isRequestPubliclyCacheable(ctx.req.raw) ctx.set('cache', caches.default) ctx.set( 'client', createAgenticClient({ env: ctx.env, cache: caches.default, isCachingEnabled, waitUntil }) ) // Resolve the edge request to a specific deployment and mode (MCP or HTTP) const resolvedEdgeRequest = await resolveEdgeRequest(ctx) // Handle MCP requests if (resolvedEdgeRequest.edgeRequestMode === 'MCP') { ctx.set('isJsonRpcRequest', true) const executionCtx = ctx.executionCtx as any const mcpInfo = await resolveMcpEdgeRequest(ctx, resolvedEdgeRequest) executionCtx.props = mcpInfo return DurableMcpServer.serve('/*', { binding: 'DO_MCP_SERVER' }).fetch(ctx.req.raw, ctx.env, executionCtx) } // Handle HTTP requests let resolvedHttpEdgeRequest: ResolvedHttpEdgeRequest | undefined let resolvedOriginToolCallResult: ResolvedOriginToolCallResult | undefined let originResponse: Response | undefined let res: Response | undefined try { // Resolve the http edge request to a specific consumer and tool call resolvedHttpEdgeRequest = await resolveHttpEdgeRequest( ctx, resolvedEdgeRequest ) // Invoke the origin tool call. resolvedOriginToolCallResult = await resolveOriginToolCall({ ...resolvedHttpEdgeRequest, args: resolvedHttpEdgeRequest.toolCallArgs, sessionId: ctx.get('sessionId')!, env: ctx.env, waitUntil }) // Transform the origin tool call response into an http response. if (resolvedOriginToolCallResult.originResponse) { originResponse = resolvedOriginToolCallResult.originResponse } else { originResponse = await createHttpResponseFromMcpToolCallResponse(ctx, { ...resolvedHttpEdgeRequest, toolCallResponse: resolvedOriginToolCallResult.toolCallResponse, toolConfig: resolvedOriginToolCallResult.toolConfig }) } assert(originResponse, 500, 'Origin response is required') // Post-process the origin response. res = updateResponse(originResponse, resolvedOriginToolCallResult) return res } catch (err: any) { // Convert the error into an http response and post-process it. res = errorHandler(err, ctx) res = updateResponse(res, resolvedOriginToolCallResult) return res } finally { // Record the tool call usage. if (resolvedHttpEdgeRequest && res) { recordToolCallUsage({ ...resolvedHttpEdgeRequest, httpResponse: res, resolvedOriginToolCallResult, sessionId: ctx.get('sessionId')!, env: ctx.env, waitUntil }) } } }) function updateResponse( response: Response, resolvedOriginToolCallResult?: ResolvedOriginToolCallResult ) { const res = new Response(response.body, response) if (resolvedOriginToolCallResult) { if (resolvedOriginToolCallResult.rateLimitResult) { applyRateLimitHeaders({ res, rateLimitResult: resolvedOriginToolCallResult.rateLimitResult }) } // Record the time it took for the origin to respond. res.headers.set( 'x-origin-response-time', `${resolvedOriginToolCallResult.originTimespanMs}ms` ) } // Reset server to Agentic because Cloudflare likes to override things res.headers.set('server', 'agentic') // Remove extra Cloudflare headers res.headers.delete('x-powered-by') res.headers.delete('via') res.headers.delete('nel') res.headers.delete('report-to') res.headers.delete('server-timing') res.headers.delete('reporting-endpoints') return res } ================================================ FILE: apps/gateway/src/lib/__snapshots__/utils.test.ts.snap ================================================ // Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html exports[`createAgenticMcpMetadata 1`] = `"{"agentic":{"deploymentId":"123","consumerId":"456","toolName":"test","status":200,"cacheStatus":"HIT"}}"`; ================================================ FILE: apps/gateway/src/lib/agentic-client.ts ================================================ import { AgenticApiClient } from '@agentic/platform-api-client' import defaultKy from 'ky' import type { RawEnv } from './env' import type { WaitUntil } from './types' import { isCacheControlPubliclyCacheable } from './utils' export function createAgenticClient({ env, cache, waitUntil, isCachingEnabled = true }: { env: RawEnv cache: Cache waitUntil: WaitUntil isCachingEnabled?: boolean }) { const client = new AgenticApiClient({ apiBaseUrl: env.AGENTIC_API_BASE_URL, apiKey: env.AGENTIC_API_KEY, ky: isCachingEnabled ? defaultKy.extend({ hooks: { // NOTE: The order of the `beforeRequest` hook matters, and it only // works alongside the one in AgenticApiClient because that one's body // should never be run. This only works because we're using `apiKey` // authentication, which is a lil hacky since it's actually a long- // lived access token. beforeRequest: [ async (request) => { // Check the cache first before making a request to Agentic's // backend API. return cache.match(request) } ], afterResponse: [ async (request, _options, response) => { if ( !isCacheControlPubliclyCacheable( response.headers.get('cache-control') ) ) { return } // Asynchronously update the cache with the response from // Agentic's backend API. waitUntil( cache.put(request, response.clone()).catch((err) => { // eslint-disable-next-line no-console console.warn('cache put error', request, err) }) ) } ] } }) : defaultKy }) return client } ================================================ FILE: apps/gateway/src/lib/cf-validate-json-schema.ts ================================================ import { Validator } from '@agentic/json-schema' import { assert, HttpError } from '@agentic/platform-core' import plur from 'plur' /** * Validates `data` against the provided JSON schema. * * This method uses a fork of `@cfworker/json-schema`. It does not use `ajv` * because `ajv` is not supported on CF workers due to its dynamic code * generation and evaluation. * * If you want a stricter version of this method which uses `ajv` and you're * not running on CF workers, consider using `validateJsonSchemaObject` from * `@agentic/platform-openapi-utils`. */ export function cfValidateJsonSchema({ schema, data, coerce = false, strictAdditionalProperties = false, errorPrefix, errorStatusCode = 400 }: { schema: any data: unknown coerce?: boolean strictAdditionalProperties?: boolean errorPrefix?: string errorStatusCode?: number }): T { assert(schema, 400, '`schema` is required') const isSchemaObject = typeof schema === 'object' && !Array.isArray(schema) && schema.type === 'object' const isDataObject = typeof data === 'object' && !Array.isArray(data) if (isSchemaObject && !isDataObject) { throw new HttpError({ statusCode: 400, message: `${errorPrefix ? errorPrefix + ': ' : ''}Data must be an object according to its schema.` }) } // Special-case check for required fields to give better error messages if (isSchemaObject && Array.isArray(schema.required)) { const missingRequiredFields: string[] = schema.required.filter( (field: string) => (data as Record)[field] === undefined ) if (missingRequiredFields.length > 0) { throw new HttpError({ statusCode: errorStatusCode, message: `${errorPrefix ? errorPrefix + ': ' : ''}Missing required ${plur('parameter', missingRequiredFields.length)}: ${missingRequiredFields.map((field) => `"${field}"`).join(', ')}` }) } } // Special-case check for additional top-level fields to give better error // messages. if ( isSchemaObject && schema.properties && (schema.additionalProperties === false || (schema.additionalProperties === undefined && strictAdditionalProperties)) ) { const extraProperties = Object.keys(data as Record).filter( (key) => !schema.properties[key] ) if (extraProperties.length > 0) { throw new HttpError({ statusCode: errorStatusCode, message: `${errorPrefix ? errorPrefix + ': ' : ''}Unexpected additional ${plur('parameter', extraProperties.length)}: ${extraProperties.map((property) => `"${property}"`).join(', ')}` }) } } const validator = new Validator({ schema, coerce, strictAdditionalProperties }) const result = validator.validate(data) if (result.valid) { // console.log('validate', { // schema, // data, // result: result.instance // }) // Return the (possibly) coerced data return result.instance as T } const finalErrorMessage = `${ errorPrefix ? errorPrefix + ': ' : '' }${result.errors .map(({ keyword, error }) => `keyword "${keyword}" error ${error}`) .join(' ')}` throw new HttpError({ statusCode: errorStatusCode, message: finalErrorMessage }) } ================================================ FILE: apps/gateway/src/lib/create-http-request-for-openapi-operation.ts ================================================ import type { AdminDeployment, OpenAPIToolOperation, ToolConfig } from '@agentic/platform-types' import { assert } from '@agentic/platform-core' import type { ToolCallArgs } from './types' export async function createHttpRequestForOpenAPIOperation({ toolCallArgs, operation, deployment, request, toolConfig }: { toolCallArgs: ToolCallArgs operation: OpenAPIToolOperation deployment: AdminDeployment request?: Request toolConfig?: ToolConfig }): Promise { assert(toolCallArgs, 500, 'Tool args are required') assert( deployment.origin.type === 'openapi', 500, `Internal logic error for origin adapter type "${deployment.origin.type}"` ) const { method } = operation const methodHasBody = method === 'post' || method === 'put' || method === 'patch' // TODO: Make this more efficient by changing the `parameterSources` data structure const params = Object.entries(operation.parameterSources) const bodyParams = params.filter(([_key, source]) => source === 'body') const formDataParams = params.filter( ([_key, source]) => source === 'formData' ) const headerParams = params.filter(([_key, source]) => source === 'header') const pathParams = params.filter(([_key, source]) => source === 'path') const queryParams = params.filter(([_key, source]) => source === 'query') const cookieParams = params.filter(([_key, source]) => source === 'cookie') assert( !cookieParams.length, 500, 'Cookie parameters for OpenAPI operations are not yet supported. If you need cookie parameter support, please contact support@agentic.so.' ) const extraArgs = toolConfig?.inputSchemaAdditionalProperties === false ? [] : // TODO: Make this more efficient... Object.keys(toolCallArgs).filter((key) => { if (bodyParams.some(([paramKey]) => paramKey === key)) return false if (formDataParams.some(([paramKey]) => paramKey === key)) return false if (headerParams.some(([paramKey]) => paramKey === key)) return false if (queryParams.some(([paramKey]) => paramKey === key)) return false if (pathParams.some(([paramKey]) => paramKey === key)) return false if (cookieParams.some(([paramKey]) => paramKey === key)) return false return true }) const extraArgsEntries = extraArgs .map((key) => [key, toolCallArgs[key]]) .filter(([, value]) => value !== undefined) const headers: Record = {} if (request) { // TODO: do we want to expose these? especially authorization? for (const [key, value] of request.headers.entries()) { headers[key] = value } } if (headerParams.length > 0) { for (const [key] of headerParams) { headers[key] = (request?.headers.get(key) as string) ?? toolCallArgs[key] } } for (const [key] of cookieParams) { headers[key] = String(toolCallArgs[key]) } let body: string | undefined if (methodHasBody) { if (bodyParams.length > 0 || !formDataParams.length) { const bodyJson = Object.fromEntries( bodyParams .map(([key]) => [key, toolCallArgs[key]]) .concat(extraArgsEntries) // Prune undefined values. We know these aren't required fields, // because the incoming request params have already been validated // against the tool's input schema. .filter(([, value]) => value !== undefined) ) body = JSON.stringify(bodyJson) headers['content-type'] = 'application/json' headers['content-length'] = body.length.toString() } else if (formDataParams.length > 0) { // TODO: Double-check FormData usage. const bodyFormData = new FormData() for (const [key] of formDataParams) { const value = toolCallArgs[key] if (value !== undefined) { bodyFormData.append(key, value) } } for (const [key, value] of extraArgsEntries) { bodyFormData.append(key, value) } body = bodyFormData.toString() headers['content-type'] = 'application/x-www-form-urlencoded' headers['content-length'] = body.length.toString() } } let path = operation.path if (pathParams.length > 0) { for (const [key] of pathParams) { const value: string = toolCallArgs[key] assert(value, 400, `Missing required parameter "${key}"`) const pathParamPlaceholder = `{${key}}` assert( path.includes(pathParamPlaceholder), 500, `Misconfigured OpenAPI deployment "${deployment.id}": invalid path "${operation.path}" missing required path parameter "${key}"` ) path = path.replaceAll(pathParamPlaceholder, value) } } assert( !/\{\w+\}/.test(path), 500, `Misconfigured OpenAPI deployment "${deployment.id}": invalid path "${operation.path}"` ) const query = new URLSearchParams() for (const [key] of queryParams) { query.set(key, toolCallArgs[key] as string) } if (!methodHasBody) { for (const [key, value] of extraArgsEntries) { query.set(key, value) } } const queryString = query.toString() const originRequestUrl = `${deployment.origin.url}${path}${ queryString ? `?${queryString}` : '' }` return new Request(originRequestUrl, { method: method.toUpperCase(), body, headers }) } ================================================ FILE: apps/gateway/src/lib/create-http-response-from-mcp-tool-call-response.ts ================================================ import type { AdminDeployment, Tool, ToolConfig } from '@agentic/platform-types' import { assert } from '@agentic/platform-core' import type { GatewayHonoContext, McpToolCallResponse } from './types' import { cfValidateJsonSchema } from './cf-validate-json-schema' export async function createHttpResponseFromMcpToolCallResponse( _ctx: GatewayHonoContext, { tool, deployment, toolCallResponse, toolConfig }: { tool: Tool deployment: AdminDeployment toolCallResponse: McpToolCallResponse toolConfig?: ToolConfig } ): Promise { assert( deployment.origin.type === 'mcp', 500, `Internal logic error for origin adapter type "${deployment.origin.type}"` ) assert( !toolCallResponse.isError, 502, // TODO: add content or structuredContent to the error message `MCP tool "${tool.name}" returned an error.` ) if (tool.outputSchema) { // eslint-disable-next-line no-console console.log(`tool call "${tool.name}" structured response:`, { outputSchema: tool.outputSchema, toolCallResponse }) assert( toolCallResponse.structuredContent, 502, `Structured content is required for MCP origin requests to tool "${tool.name}" because it has an output schema.` ) // Validate tool response against the tool's output schema. const toolCallResponseContent = cfValidateJsonSchema({ schema: tool.outputSchema, data: toolCallResponse.structuredContent as Record, coerce: false, // TODO: double-check MCP schema on whether additional properties are allowed strictAdditionalProperties: toolConfig?.outputSchemaAdditionalProperties === false, errorPrefix: `Invalid tool response for tool "${tool.name}"`, errorStatusCode: 502 }) return new Response(JSON.stringify(toolCallResponseContent), { headers: { 'content-type': 'application/json' } }) } return new Response(JSON.stringify(toolCallResponse.content), { headers: { 'content-type': 'application/json' } }) } ================================================ FILE: apps/gateway/src/lib/durable-mcp-client.ts ================================================ import type { AgenticMcpRequestMetadata } from '@agentic/platform-types' import { assert } from '@agentic/platform-core' import { Client as McpClient } from '@modelcontextprotocol/sdk/client/index.js' import { StreamableHTTPClientTransport } from '@modelcontextprotocol/sdk/client/streamableHttp.js' import * as Sentry from '@sentry/cloudflare' import { DurableObject } from 'cloudflare:workers' import type { RawEnv } from './env' export type DurableMcpClientInfo = { url: string name: string version: string headers?: Record } // TODO: not sure if there's a better way to handle re-using client connections // across requests. Maybe we use one DurableObject per unique // customer<>DurableMcpClientInfo connection? // Currently using `sessionId` export class DurableMcpClientBase extends DurableObject { protected client?: McpClient protected clientConnectionP?: Promise async init(mcpClientInfo: DurableMcpClientInfo) { const existingMcpClientInfo = await this.ctx.storage.get('mcp-client-info') await this.ctx.storage.put('mcp-client-info', mcpClientInfo) if (existingMcpClientInfo) { if (mcpClientInfo.url !== existingMcpClientInfo.url) { // eslint-disable-next-line no-console console.warn( `DurableMcpClientInfo url changed from "${existingMcpClientInfo.url}" to "${mcpClientInfo.url}"` ) } await this.client?.close() this.clientConnectionP = undefined this.client = undefined } return this.ensureClientConnection(mcpClientInfo) } async isInitialized(): Promise { return !!(await this.ctx.storage.get('mcp-client-info')) } async ensureClientConnection(mcpClientInfo?: DurableMcpClientInfo) { if (this.clientConnectionP) return this.clientConnectionP mcpClientInfo ??= await this.ctx.storage.get('mcp-client-info') assert(mcpClientInfo, 500, 'DurableMcpClient has not been initialized') const { name, version, url } = mcpClientInfo this.client = new McpClient({ name, version }) // console.log('DurableMcpClient.ensureClientConnection', { // url, // headers: mcpClientInfo.headers // }) const transport = new StreamableHTTPClientTransport(new URL(url), { requestInit: { headers: mcpClientInfo.headers } }) this.clientConnectionP = this.client.connect(transport) await this.clientConnectionP } async callTool({ name, args, metadata }: { name: string args: Record metadata: AgenticMcpRequestMetadata }): Promise { await this.ensureClientConnection() // console.log('DurableMcpClient.callTool', { // name, // args, // metadata // }) const toolCallResponse = await this.client!.callTool({ name, arguments: args, _meta: { agentic: metadata } }) // TODO: The `McpToolCallResponse` type is seemingly too complex for the CF // serialization type inference to handle, so bypass it by serializing to // a string and parsing it on the other end. return JSON.stringify(toolCallResponse) } } export const DurableMcpClient = Sentry.instrumentDurableObjectWithSentry( (env: RawEnv) => ({ dsn: env.SENTRY_DSN, environment: env.ENVIRONMENT, integrations: [Sentry.extraErrorDataIntegration()] }), DurableMcpClientBase ) ================================================ FILE: apps/gateway/src/lib/durable-mcp-server.ts ================================================ import { assert, getRateLimitHeaders } from '@agentic/platform-core' import { parseDeploymentIdentifier } from '@agentic/platform-validators' import { Server } from '@modelcontextprotocol/sdk/server/index.js' import { CallToolRequestSchema, ListToolsRequestSchema } from '@modelcontextprotocol/sdk/types.js' import * as Sentry from '@sentry/cloudflare' import { McpAgent } from 'agents/mcp' import type { RawEnv } from './env' import type { McpToolCallResponse, ResolvedMcpEdgeRequest, ResolvedOriginToolCallResult } from './types' import { handleMcpToolCallError } from './handle-mcp-tool-call-error' import { recordToolCallUsage } from './record-tool-call-usage' import { resolveOriginToolCall } from './resolve-origin-tool-call' import { transformHttpResponseToMcpToolCallResponse } from './transform-http-response-to-mcp-tool-call-response' import { createAgenticMcpMetadata } from './utils' export class DurableMcpServerBase extends McpAgent< RawEnv, never, // We aren't currently using local state, so set it to `never`. ResolvedMcpEdgeRequest > { protected _serverP = Promise.withResolvers() override server = this._serverP.promise // NOTE: This empty constructor is required for the Sentry wrapper to work. public constructor(state: DurableObjectState, env: RawEnv) { super(state, env) } override async init() { const { consumer, deployment, pricingPlan } = this.props const { projectIdentifier } = parseDeploymentIdentifier( deployment.identifier ) const server = new Server( { name: projectIdentifier, version: deployment.version ?? '0.0.0' }, { capabilities: { tools: {} } } ) this._serverP.resolve(server) const tools = deployment.tools .map((tool) => { const toolConfig = deployment.toolConfigs.find( (toolConfig) => toolConfig.name === tool.name ) if (toolConfig) { const pricingPlanToolOverride = pricingPlan ? toolConfig.pricingPlanOverridesMap?.[pricingPlan.slug] : undefined if (pricingPlanToolOverride?.enabled === true) { // Tool is explicitly enabled for the customer's pricing plan } else if (pricingPlanToolOverride?.enabled === false) { // Tool is disabled for the customer's pricing plan return undefined } else if (toolConfig.enabled === false) { // Tool is disabled for all pricing plans return undefined } } return tool }) .filter(Boolean) server.setRequestHandler(ListToolsRequestSchema, async () => ({ tools })) server.setRequestHandler(CallToolRequestSchema, async (request) => { const { name: toolName, arguments: args, _meta } = request.params const sessionId = this.ctx.id.toString() const tool = tools.find((tool) => tool.name === toolName) const cacheControl = (_meta?.agentic as any)?.headers?.['cache-control'] let resolvedOriginToolCallResult: ResolvedOriginToolCallResult | undefined let toolCallResponse: McpToolCallResponse | undefined try { assert(tool, 404, `Unknown tool "${toolName}"`) resolvedOriginToolCallResult = await resolveOriginToolCall({ ...this.props, tool, args, cacheControl, sessionId, env: this.env, waitUntil: this.ctx.waitUntil.bind(this.ctx) }) if (resolvedOriginToolCallResult.originResponse) { toolCallResponse = await transformHttpResponseToMcpToolCallResponse({ ...resolvedOriginToolCallResult, tool }) } else { toolCallResponse = resolvedOriginToolCallResult.toolCallResponse assert(toolCallResponse, 500, 'Missing tool call response') } return toolCallResponse } catch (err: unknown) { // Gracefully handle tool call exceptions, whether they were thrown by // the origin server or internally by the gateway. toolCallResponse = handleMcpToolCallError(err, { toolName, env: this.env }) return toolCallResponse } finally { assert(toolCallResponse, 500, 'Missing tool call response') // Augment the MCP tool call response with agentic metadata, which // makes it easier to debug tool calls and adds some much-needed HTTP // header-like functionality to tool call responses. toolCallResponse._meta = createAgenticMcpMetadata( { deploymentId: deployment.id, consumerId: consumer?.id, toolName, cacheStatus: resolvedOriginToolCallResult?.cacheStatus, headers: getRateLimitHeaders( resolvedOriginToolCallResult?.rateLimitResult ) }, toolCallResponse._meta ) // Record tool call usage, whether the call was successful or not. recordToolCallUsage({ ...this.props, tool, mcpToolCallResponse: toolCallResponse!, resolvedOriginToolCallResult, sessionId, env: this.env, waitUntil: this.ctx.waitUntil.bind(this.ctx) }) } }) } } export const DurableMcpServer = Sentry.instrumentDurableObjectWithSentry( (env: RawEnv) => ({ dsn: env.SENTRY_DSN, environment: env.ENVIRONMENT, integrations: [Sentry.extraErrorDataIntegration()] }), DurableMcpServerBase ) ================================================ FILE: apps/gateway/src/lib/env.ts ================================================ import type { AnalyticsEngineDataset, DurableObjectNamespace } from '@cloudflare/workers-types' import type { Simplify } from 'type-fest' import { parseZodSchema } from '@agentic/platform-core' import { envSchema as baseEnvSchema, parseEnv as parseBaseEnv } from '@agentic/platform-hono' import { z } from 'zod' export const envSchema = baseEnvSchema .extend({ AGENTIC_API_BASE_URL: z.string().url(), AGENTIC_API_KEY: z.string().nonempty(), STRIPE_SECRET_KEY: z.string().nonempty(), DO_RATE_LIMITER: z.custom((ns) => isDurableObjectNamespace(ns) ), DO_MCP_SERVER: z.custom((ns) => isDurableObjectNamespace(ns) ), DO_MCP_CLIENT: z.custom((ns) => isDurableObjectNamespace(ns) ), AE_USAGE_DATASET: z.custom((ae) => isAnalyticsEngineDataset(ae) ) }) .strip() export type RawEnv = z.infer export function isDurableObjectNamespace( namespace: unknown ): namespace is DurableObjectNamespace { return ( !!namespace && typeof namespace === 'object' && 'newUniqueId' in namespace && typeof namespace.newUniqueId === 'function' && 'idFromName' in namespace && typeof namespace.idFromName === 'function' ) } function isAnalyticsEngineDataset(ae: unknown): ae is AnalyticsEngineDataset { return !!ae && typeof ae === 'object' && 'writeDataPoint' in ae } export function parseEnv(inputEnv: Record) { const baseEnv = parseBaseEnv({ SERVICE: 'gateway', ...inputEnv }) const env = parseZodSchema( envSchema, { ...inputEnv, ...baseEnv }, { error: 'Invalid environment variables' } ) const isStripeLive = env.STRIPE_SECRET_KEY.startsWith('sk_live_') return { ...baseEnv, ...env, isStripeLive } } export type Env = Simplify> ================================================ FILE: apps/gateway/src/lib/external/stripe.ts ================================================ import Stripe from 'stripe' import type { RawEnv } from '../env' export function createStripe(env: RawEnv): Stripe { return new Stripe(env.STRIPE_SECRET_KEY, { apiVersion: '2025-06-30.basil' }) } ================================================ FILE: apps/gateway/src/lib/fetch-cache.ts ================================================ import type { WaitUntil } from './types' export async function fetchCache({ cacheKey, fetchResponse, waitUntil }: { cacheKey?: Request fetchResponse: () => Promise waitUntil: WaitUntil }): Promise { const cache = caches.default let response: Response | undefined if (cacheKey) { response = await cache.match(cacheKey) } if (!response) { response = await fetchResponse() response = new Response(response.body, response) if (cacheKey) { if (response.headers.has('Cache-Control')) { // Note that cloudflare's `cache` should respect response headers. waitUntil( cache.put(cacheKey, response.clone()).catch((err) => { // eslint-disable-next-line no-console console.warn('cache put error', cacheKey, err) }) ) } response.headers.set('cf-cache-status', 'MISS') } else { response.headers.set('cf-cache-status', 'BYPASS') } } return response } ================================================ FILE: apps/gateway/src/lib/get-admin-consumer.ts ================================================ import { assert, HttpError } from '@agentic/platform-core' import type { AdminConsumer, GatewayHonoContext } from './types' export async function getAdminConsumer( ctx: GatewayHonoContext, apiKey: string ): Promise { const client = ctx.get('client') let consumer: AdminConsumer | undefined try { consumer = await client.adminGetConsumerByApiKey({ apiKey, populate: ['user'] }) } catch (err: any) { if (err.response?.status === 404) { // Hide the underlying error message from the client throw new HttpError({ statusCode: 404, message: `API key not found "${apiKey}"`, cause: err }) } throw err } assert(consumer, 404, `API key not found "${apiKey}"`) return consumer } ================================================ FILE: apps/gateway/src/lib/get-admin-deployment.ts ================================================ import type { AdminDeployment } from '@agentic/platform-types' import { assert } from '@agentic/platform-core' import { parseDeploymentIdentifier } from '@agentic/platform-validators' import type { GatewayHonoContext } from './types' export async function getAdminDeployment( ctx: GatewayHonoContext, identifier: string ): Promise { const parsedDeploymentIdentifier = parseDeploymentIdentifier(identifier, { strict: true, errorStatusCode: 404 }) const client = ctx.get('client') const deployment = await client.adminGetDeploymentByIdentifier({ deploymentIdentifier: parsedDeploymentIdentifier.deploymentIdentifier }) assert(deployment, 404, `Deployment not found "${identifier}"`) return deployment } ================================================ FILE: apps/gateway/src/lib/get-request-cache-key.ts ================================================ import { hashObject, sha256 } from '@agentic/platform-core' import contentType from 'fast-content-type-parse' import { normalizeUrl } from './normalize-url' import { isRequestPubliclyCacheable } from './utils' // TODO: what is a reasonable upper bound for hashing the POST body size? const MAX_POST_BODY_SIZE_BYTES = 10_000 export async function getRequestCacheKey( request: Request ): Promise { try { if (!isRequestPubliclyCacheable(request)) { return } if ( request.method === 'POST' || request.method === 'PUT' || request.method === 'PATCH' ) { const contentLength = Number.parseInt( request.headers.get('content-length') ?? '0' ) if (contentLength < MAX_POST_BODY_SIZE_BYTES) { const { type } = contentType.safeParse( request.headers.get('content-type') || 'application/octet-stream' ) let hash = '___AGENTIC_CACHE_KEY_EMPTY_BODY___' if (contentLength > 0) { if (type.includes('json')) { const bodyJson: any = await request.clone().json() hash = await hashObject(bodyJson) } else if (type.includes('text/')) { const bodyString = await request.clone().text() hash = await sha256(bodyString) } else { const bodyBuffer = await request.clone().arrayBuffer() hash = await sha256(bodyBuffer) } } const cacheUrl = new URL(request.url) cacheUrl.searchParams.set('x-agentic-cache-key', hash) const normalizedUrl = normalizeUrl(cacheUrl.toString()) // Convert POST and PUT requests to GET with a query param containing // a hash of the request body. This enables us to cache these requests // more easily, since we want to move the the "cacheability" logic to a // higher-level, config-based approach. E.g., individual tools can // opt-in to aggressive caching by declaring themselves `pure` or // `immutable` regardless of the HTTP method used to call the tool. const newReq = normalizeRequestHeaders( new Request(normalizedUrl, { headers: request.headers, method: 'GET' }) ) return newReq } } else if (request.method === 'GET' || request.method === 'HEAD') { const url = request.url const normalizedUrl = normalizeUrl(url) if (url !== normalizedUrl) { return normalizeRequestHeaders( new Request(normalizedUrl, { method: request.method }) ) } } return normalizeRequestHeaders(new Request(request)) } catch (err) { // eslint-disable-next-line no-console console.warn( 'warning: failed to compute cache key', request.method, request.url, err ) } } const requestHeaderWhitelist = new Set([ 'cache-control', 'content-type', 'mcp-session-id' ]) function normalizeRequestHeaders(request: Request) { const headers = Object.fromEntries(request.headers.entries()) const keys = Object.keys(headers) for (const key of keys) { if (!requestHeaderWhitelist.has(key)) { request.headers.delete(key) } } return request } ================================================ FILE: apps/gateway/src/lib/get-tool-args-from-request.ts ================================================ import type { AdminDeployment, Tool } from '@agentic/platform-types' import { assert, HttpError } from '@agentic/platform-core' import type { GatewayHonoContext } from './types' import { cfValidateJsonSchema } from './cf-validate-json-schema' export async function getToolArgsFromRequest( ctx: GatewayHonoContext, { tool, deployment }: { tool: Tool deployment: AdminDeployment } ): Promise> { const logger = ctx.get('logger') const request = ctx.req.raw assert( deployment.origin.type !== 'raw', 500, `Internal logic error for origin adapter type "${deployment.origin.type}"` ) if (request.method === 'GET') { // Args will be coerced to match their expected types via // `cfValidateJsonSchemaObject` since all values will be strings. const incomingRequestArgsRaw = Object.fromEntries( new URL(request.url).searchParams.entries() ) const toolConfig = deployment.toolConfigs.find( (toolConfig) => toolConfig.name === tool.name ) // Validate incoming request params against the tool's input schema. const incomingRequestArgs = cfValidateJsonSchema>({ schema: tool.inputSchema, data: incomingRequestArgsRaw, errorPrefix: `Invalid request parameters for tool "${tool.name}"`, coerce: true, strictAdditionalProperties: toolConfig?.inputSchemaAdditionalProperties === false }) return incomingRequestArgs } else if (request.method === 'POST') { let incomingRequestArgsRaw: unknown = {} // TODO: verify content-type of request is application/json try { incomingRequestArgsRaw = (await request.json()) as Record } catch (err) { // Error if the request body is not JSON or is malformed. logger.error('Error parsing incoming request body', request, err) throw new HttpError({ message: 'Invalid request body json', statusCode: 400, cause: err }) } // console.log( // 'incomingRequestArgsRaw', // typeof incomingRequestArgsRaw, // request.headers, // incomingRequestArgsRaw // ) // TODO: Proper support for empty params with POST requests assert(incomingRequestArgsRaw, 400, 'Invalid empty request body') assert( typeof incomingRequestArgsRaw === 'object', 400, `Invalid request body: expected type "object", received type "${typeof incomingRequestArgsRaw}"` ) assert(!Array.isArray(incomingRequestArgsRaw), 400, 'Invalid request body') return incomingRequestArgsRaw } else { assert(false, 405, `HTTP method "${request.method}" not allowed`) } } ================================================ FILE: apps/gateway/src/lib/get-tool.ts ================================================ import type { AdminDeployment, Tool } from '@agentic/platform-types' import { assert } from '@agentic/platform-core' export function getTool({ toolName, deployment, method, strict = false }: { toolName: string deployment: AdminDeployment method?: string strict?: boolean }): Tool { assert(toolName, 404, `Invalid input empty tool name`) let tool = deployment.tools.find((tool) => tool.name === toolName) if (!tool && !strict) { if (deployment.origin.type === 'openapi') { // Check if the tool name is an operation ID since it's easy to forget // and mistake the two (`getPost` vs `get_post`). // TODO: In the future, we should be consistent about how we handle tool // names. Do we always allow camelCase and snake_case, or do we just allow // alternates for operationIds? We should also make sure alternates are // uniquely defined. const operationToolName = Object.entries( deployment.origin.toolToOperationMap ).find(([_, operation]) => { if (operation.operationId === toolName) { return true } return false })?.[0] if (operationToolName) { tool = deployment.tools.find((tool) => tool.name === operationToolName) assert( tool, 404, `Tool not found "${toolName}" for deployment "${deployment.identifier}": did you mean "${operationToolName}"?` ) } } } assert( tool, 404, `Tool not found "${toolName}" for deployment "${deployment.identifier}"` ) if (deployment.origin.type === 'openapi') { const operation = deployment.origin.toolToOperationMap[tool.name] assert( operation, 404, `OpenAPI operation not found for tool "${tool.name}"` ) if (method) { assert( method === 'GET' || method === 'POST', 405, `Invalid HTTP method "${method}" for tool "${tool.name}"` ) } } return tool } ================================================ FILE: apps/gateway/src/lib/handle-mcp-tool-call-error.ts ================================================ import type { ContentfulStatusCode } from 'hono/utils/http-status' import { HttpError } from '@agentic/platform-core' import { suppressedHttpStatuses } from '@agentic/platform-hono' import * as Sentry from '@sentry/cloudflare' import { HTTPException } from 'hono/http-exception' import { HTTPError } from 'ky' import type { RawEnv } from './env' import type { McpToolCallResponse } from './types' /** * Turns a thrown error into an MCP error tool call response, and attempts to * capture as much context as possible for potential debugging. * * @note This function is synchronous and should never throw. */ export function handleMcpToolCallError( err: any, { toolName, env }: { toolName: string env: RawEnv } ): McpToolCallResponse { const isProd = env.ENVIRONMENT === 'production' let message = 'Internal Server Error' let status: ContentfulStatusCode = 500 const res: McpToolCallResponse = { _meta: { agentic: { toolName, headers: {} } }, isError: true, content: [ { type: 'text', text: message } ] } if (err instanceof HttpError) { message = err.message status = err.statusCode as ContentfulStatusCode // This is where rate-limit headers will be set, since `RateLimitError` // is a subclass of `HttpError`. if (err.headers) { for (const [key, value] of Object.entries(err.headers)) { ;(res._meta!.agentic as any).headers[key] = value } } } else if (err instanceof HTTPException) { message = err.message status = err.status } else if (err instanceof HTTPError) { message = err.message status = err.response.status as ContentfulStatusCode } else if (!isProd && err.message) { message = err.message } if (!Number.isSafeInteger(status)) { status = 500 } if (!suppressedHttpStatuses.has(status)) { if (status >= 500) { // eslint-disable-next-line no-console console.error(`mcp tool call "${toolName}" error`, status, err) if (isProd) { try { Sentry.captureException(err) } catch (err_) { // eslint-disable-next-line no-console console.error('Error Sentry.captureException failed', err, err_) } } } else { // eslint-disable-next-line no-console console.warn(`mcp tool call "${toolName}" warning`, status, err) } } ;(res._meta!.agentic as any).status = status res.content = [ { type: 'text', text: message } ] return res } ================================================ FILE: apps/gateway/src/lib/normalize-url.test.ts ================================================ import { expect, test } from 'vitest' import { normalizeUrl } from './normalize-url' test('main', () => { expect(normalizeUrl('http://sindresorhus.com')).toBe( 'https://sindresorhus.com' ) expect(normalizeUrl('http://sindresorhus.com ')).toBe( 'https://sindresorhus.com' ) expect(normalizeUrl('http://sindresorhus.com.')).toBe( 'https://sindresorhus.com' ) expect(normalizeUrl('http://SindreSorhus.com')).toBe( 'https://sindresorhus.com' ) expect(normalizeUrl('http://sindresorhus.com')).toBe( 'https://sindresorhus.com' ) expect(normalizeUrl('HTTP://sindresorhus.com')).toBe( 'https://sindresorhus.com' ) // TODO: why isn't this parsed correctly by Node.js URL? // t.is(normalizeUrl('//sindresorhus.com'), 'https://sindresorhus.com') expect(normalizeUrl('http://sindresorhus.com')).toBe( 'https://sindresorhus.com' ) expect(normalizeUrl('http://sindresorhus.com:80')).toBe( 'https://sindresorhus.com' ) expect(normalizeUrl('https://sindresorhus.com:443')).toBe( 'https://sindresorhus.com' ) expect(normalizeUrl('ftp://sindresorhus.com:21')).toBe( 'ftp://sindresorhus.com' ) expect(normalizeUrl('https://sindresorhus.com/foo/')).toBe( 'https://sindresorhus.com/foo' ) expect(normalizeUrl('http://sindresorhus.com/?foo=bar baz')).toBe( 'https://sindresorhus.com/?foo=bar+baz' ) expect(normalizeUrl('https://foo.com/https://bar.com')).toBe( 'https://foo.com/https://bar.com' ) expect(normalizeUrl('https://foo.com/https://bar.com/foo//bar')).toBe( 'https://foo.com/https://bar.com/foo/bar' ) expect(normalizeUrl('https://foo.com/http://bar.com')).toBe( 'https://foo.com/http://bar.com' ) expect(normalizeUrl('https://foo.com/http://bar.com/foo//bar')).toBe( 'https://foo.com/http://bar.com/foo/bar' ) expect(normalizeUrl('https://foo.com/http://bar.com/foo//bar')).toBe( 'https://foo.com/http://bar.com/foo/bar' ) expect(normalizeUrl('https://sindresorhus.com/%7Efoo/')).toBe( 'https://sindresorhus.com/~foo' ) expect(normalizeUrl('https://sindresorhus.com/?')).toBe( 'https://sindresorhus.com' ) expect(normalizeUrl('https://êxample.com')).toBe('https://xn--xample-hva.com') expect( normalizeUrl('https://sindresorhus.com/?b=bar&a=foo'), 'https://sindresorhus.com/?a=foo&b=bar' ) expect(normalizeUrl('https://sindresorhus.com/?foo=bar*|<>:"')).toBe( 'https://sindresorhus.com/?foo=bar*%7C%3C%3E%3A%22' ) expect(normalizeUrl('https://sindresorhus.com:5000')).toBe( 'https://sindresorhus.com:5000' ) expect(normalizeUrl('https://sindresorhus.com/foo#bar')).toBe( 'https://sindresorhus.com/foo#bar' ) expect(normalizeUrl('https://sindresorhus.com/foo/bar/../baz')).toBe( 'https://sindresorhus.com/foo/baz' ) expect(normalizeUrl('https://sindresorhus.com/foo/bar/./baz')).toBe( 'https://sindresorhus.com/foo/bar/baz' ) expect( normalizeUrl( 'https://i.vimeocdn.com/filter/overlay?src0=https://i.vimeocdn.com/video/598160082_1280x720.jpg&src1=https://f.vimeocdn.com/images_v6/share/play_icon_overlay.png' ) ).toBe( 'https://i.vimeocdn.com/filter/overlay?src0=https%3A%2F%2Fi.vimeocdn.com%2Fvideo%2F598160082_1280x720.jpg&src1=https%3A%2F%2Ff.vimeocdn.com%2Fimages_v6%2Fshare%2Fplay_icon_overlay.png' ) }) test('removeTrailingSlash and removeDirectoryIndex options)', () => { expect(normalizeUrl('https://sindresorhus.com/path/')).toBe( 'https://sindresorhus.com/path' ) expect(normalizeUrl('https://sindresorhus.com/#/path/')).toBe( 'https://sindresorhus.com/#/path/' ) expect(normalizeUrl('https://sindresorhus.com/foo/#/bar/')).toBe( 'https://sindresorhus.com/foo#/bar/' ) }) test('sortQueryParameters', () => { expect(normalizeUrl('https://sindresorhus.com/?a=Z&b=Y&c=X&d=W')).toBe( 'https://sindresorhus.com/?a=Z&b=Y&c=X&d=W' ) expect(normalizeUrl('https://sindresorhus.com/?b=Y&c=X&a=Z&d=W')).toBe( 'https://sindresorhus.com/?a=Z&b=Y&c=X&d=W' ) expect(normalizeUrl('https://sindresorhus.com/?a=Z&d=W&b=Y&c=X')).toBe( 'https://sindresorhus.com/?a=Z&b=Y&c=X&d=W' ) expect(normalizeUrl('https://sindresorhus.com/')).toBe( 'https://sindresorhus.com' ) }) test('invalid urls', () => { expect(() => { normalizeUrl('http://') }).toThrow('Invalid URL') expect(() => { normalizeUrl('/') }).toThrow('Invalid URL') expect(() => { normalizeUrl('/relative/path/') }).toThrow('Invalid URL') }) test('remove duplicate pathname slashes', () => { expect(normalizeUrl('https://sindresorhus.com////foo/bar')).toBe( 'https://sindresorhus.com/foo/bar' ) expect(normalizeUrl('https://sindresorhus.com////foo////bar')).toBe( 'https://sindresorhus.com/foo/bar' ) expect(normalizeUrl('ftp://sindresorhus.com//foo')).toBe( 'ftp://sindresorhus.com/foo' ) expect(normalizeUrl('https://sindresorhus.com:5000///foo')).toBe( 'https://sindresorhus.com:5000/foo' ) expect(normalizeUrl('https://sindresorhus.com///foo')).toBe( 'https://sindresorhus.com/foo' ) expect(normalizeUrl('https://sindresorhus.com:5000//foo')).toBe( 'https://sindresorhus.com:5000/foo' ) expect(normalizeUrl('https://sindresorhus.com//foo')).toBe( 'https://sindresorhus.com/foo' ) }) ================================================ FILE: apps/gateway/src/lib/normalize-url.ts ================================================ /** * Stripped down version of [normalize-url](https://github.com/sindresorhus/normalize-url) * by sindresorhus * * - always converts http => https * - removed unused options * - removed dataURL support */ export function normalizeUrl(url: string): string { const urlObj = new URL(url) if (urlObj.protocol === 'http:') { urlObj.protocol = 'https:' } /* // Remove auth // TODO: Cloudflare Workers seems to have a subtle bug where if you set URL.username and // URL.password at all, it will include the @ authentication prefix in the resulting URL. // This does not repro in normal web or Node.js contexts. if (options.stripAuthentication) { urlObj.username = '' urlObj.password = '' } */ // Remove duplicate slashes if not preceded by a protocol if (urlObj.pathname) { urlObj.pathname = urlObj.pathname.replaceAll(/(? = { current: 0 } export class DurableRateLimiterBase extends DurableObject { async update({ intervalMs, cost = 1 }: { intervalMs: number cost?: number }): Promise { const existingState = await this.ctx.storage.get('value') const currentAlarm = await this.ctx.storage.getAlarm() const now = Date.now() const updatedResetTimeMs = now + intervalMs // Update the payload const state = existingState && currentAlarm && currentAlarm > now ? existingState : { current: 0, resetTimeMs: updatedResetTimeMs } state.current += cost // Update the alarm if (!currentAlarm || currentAlarm <= now) { await this.ctx.storage.setAlarm(state.resetTimeMs) } await this.ctx.storage.put('value', state) // const updatedState = await this.ctx.storage.get('value') // console.log('DurableRateLimiter.update', this.ctx.id.toString(), { // existingState, // state, // updatedState, // now, // intervalMs, // updatedResetTimeMs, // currentAlarm // }) return state } async reset() { // console.log('reset rate-limit', this.ctx.id) await this.ctx.storage.put('value', initialState) } override async alarm() { await this.reset() } } export const DurableRateLimiter = Sentry.instrumentDurableObjectWithSentry( (env: RawEnv) => ({ dsn: env.SENTRY_DSN, environment: env.ENVIRONMENT, integrations: [Sentry.extraErrorDataIntegration()] }), DurableRateLimiterBase ) ================================================ FILE: apps/gateway/src/lib/rate-limits/enforce-rate-limit.ts ================================================ import type { RateLimit } from '@agentic/platform-types' import { assert } from '@agentic/platform-core' import type { RawEnv } from '../env' import type { RateLimitCache, RateLimitResult, RateLimitState, WaitUntil } from '../types' import type { DurableRateLimiterBase } from './durable-rate-limiter' /** * This maps persists across worker executions and is used for caching active * rate limits. It's purely a performance optimization and is not used as a * source of truth. */ const globalRateLimitCache: RateLimitCache = new Map() export async function enforceRateLimit({ rateLimit, id, cost = 1, env, cache = globalRateLimitCache, waitUntil }: { /** * The rate limit to enforce. */ rateLimit: RateLimit /** * The identifier used to uniquely track this rate limit. */ id: string /** * The cost of the request. * * @default 1 */ cost?: number env: RawEnv cache?: RateLimitCache waitUntil: WaitUntil }): Promise { if (rateLimit.enabled === false) { return } assert(id, 400, 'Unauthenticated requests must have a valid IP address') const { interval, limit, mode } = rateLimit const intervalMs = interval * 1000 const now = Date.now() const initialRateLimitState = cache.get(id) ?? { current: 0, resetTimeMs: now + intervalMs } let rateLimitState = initialRateLimitState function updateCache(info: RateLimitState) { cache.set(id, info) rateLimitState = info } /** * Short-circuit check for active rate limits that are currently exceeded. * * This might not happen too often, but in extreme cases the cache should hit * and we can skip the request to the durable object entirely, which speeds * everything up and is cheaper for us. */ if (rateLimitState.current > limit && now <= rateLimitState.resetTimeMs) { return { id, passed: false, current: rateLimitState.current, limit, resetTimeMs: rateLimitState.resetTimeMs, intervalMs, remaining: Math.max(0, limit - rateLimitState.current) } } const durableRateLimiterId = env.DO_RATE_LIMITER.idFromName(id) const durableRateLimiter = env.DO_RATE_LIMITER.get( durableRateLimiterId ) as DurableObjectStub const updatedRateLimitStateP = durableRateLimiter.update({ cost, intervalMs }) if (mode === 'strict') { const updatedRateLimitState = await updatedRateLimitStateP updateCache(updatedRateLimitState) } else { waitUntil( updatedRateLimitStateP .then((updatedRateLimitState: RateLimitState) => { updateCache(updatedRateLimitState) }) .catch((err: Error) => { // eslint-disable-next-line no-console console.error( `error updating rate limit for id "${id}": ${err.message}` ) }) ) rateLimitState.current += cost updateCache(rateLimitState) } // console.log('rateLimit', { // id, // initial: initialRateLimitState, // current: rateLimitState, // mode, // cost // }) return { id, passed: rateLimitState.current <= limit, limit, current: rateLimitState.current, remaining: Math.max(0, limit - rateLimitState.current), resetTimeMs: rateLimitState.resetTimeMs, intervalMs } } ================================================ FILE: apps/gateway/src/lib/record-tool-call-usage.ts ================================================ import type { AdminDeployment, PricingPlan, Tool } from '@agentic/platform-types' import type { RawEnv } from './env' import type { AdminConsumer, EdgeRequestMode, McpToolCallResponse, ResolvedOriginToolCallResult, WaitUntil } from './types' import { createAgenticClient } from './agentic-client' import { createStripe } from './external/stripe' /** * Records usage data to Cloudflare Analytics Engine. * * Also asynchronously activates the `consumer` if it's present and hasn't been * activated yet. * * Also asynchronously reports usage to Stripe if `consumer` is present and * `resolvedOriginToolCallResult.reportUsage` is `true`. * * @note This function should be **synchronous**. Any asynchronous operations * should use the `waitUntil` callback to not block the response from returning * to the end user promptly. * * @see https://developers.cloudflare.com/analytics/analytics-engine/limits/ */ export function recordToolCallUsage({ edgeRequestMode, deployment, consumer, tool, resolvedOriginToolCallResult, httpResponse, mcpToolCallResponse, ip, sessionId, requestId, env, waitUntil }: { edgeRequestMode: EdgeRequestMode deployment: AdminDeployment consumer?: AdminConsumer pricingPlan?: PricingPlan tool?: Tool resolvedOriginToolCallResult?: ResolvedOriginToolCallResult httpResponse?: Response mcpToolCallResponse?: McpToolCallResponse ip?: string sessionId: string requestId?: string env: RawEnv waitUntil: WaitUntil } & ( | { // For http requests, an http response is required. edgeRequestMode: 'HTTP' httpResponse: Response mcpToolCallResponse?: never } | { // For mcp cool call requests, an mcp tool call response is required. edgeRequestMode: 'MCP' httpResponse?: never mcpToolCallResponse: McpToolCallResponse } )): void { const { projectId } = deployment const { rateLimitResult, cacheStatus, originTimespanMs, toolCallArgs, numRequestsCost, reportUsage } = resolvedOriginToolCallResult ?? { numRequestsCost: 0, reportUsage: false } mcpToolCallResponse ??= resolvedOriginToolCallResult?.toolCallResponse const requestSize = toolCallArgs ? JSON.stringify(toolCallArgs).length : 0 const responseSize = Number.parseInt(httpResponse?.headers.get('content-length') ?? '0') || (mcpToolCallResponse ? JSON.stringify(mcpToolCallResponse).length : 0) // The string dimensions used for grouping and filtering (sometimes called // labels in other metrics systems). // NOTE: The ordering of these fields is important and must remain consistent! // Max of 20 blobs with total size <= 5120 bytes. const blobs = [ // Project ID of the request projectId, // Deployment ID of the request deployment.id, // Name of the tool that was called tool?.name ?? null, // Whether this request was made via MCP or HTTP edgeRequestMode, // IP address or session ID ip ?? sessionId, // Request customer ID consumer?.id ?? null, // Request customer subscription plan consumer?.plan ?? null, // Request customer subscription status consumer?.stripeStatus ?? null, // Whether the request was rate-limited rateLimitResult ? rateLimitResult.passed ? 'rl-passed' : 'rl-exceeded' : mcpToolCallResponse?._meta?.status === 429 ? 'rl-exceeded' : null, // Whether the request hit the cache cacheStatus ?? null, // HTTP response status httpResponse?.status?.toString() || (mcpToolCallResponse ? mcpToolCallResponse._meta?.status?.toString() || (mcpToolCallResponse?.isError ? 'error' : '200') : 'error') ] // Numberic values to record in this data point. // NOTE: The ordering of these fields is important and must remain consistent! const doubles = [ // Origin timespan in milliseconds originTimespanMs ?? 0, // Request bandwidth in bytes requestSize, // Response bandwidth in bytes responseSize, // Total bandwidth in bytes // TODO: Correctly calculate total bandwidth using `content-length` requestSize + responseSize, // Number of requests cost numRequestsCost ?? 0 ] // Cloudflare Analytics Engine only supports writing a single index at a time, // so we associate this usage with the project. // TODO: Should we also index based on customer ID / IP? Or is being able to // filter by these fields in the project's `blobs` enough? env.AE_USAGE_DATASET.writeDataPoint({ indexes: [projectId], blobs, doubles }) if (consumer && !consumer.activated) { const client = createAgenticClient({ env, cache: caches.default, waitUntil, isCachingEnabled: false }) // If there's a consumer and it hasn't been activated yet, make sure it's // activated. This may be called multiple times if the consumer is cached, // but this method is intentionally idempotent, and we don't cache non- // activated consumers for long, so it shouldn't be a problem. waitUntil(client.adminActivateConsumer({ consumerId: consumer.id })) } if (consumer && reportUsage) { const stripe = createStripe(env) const pricingPlanLineItemSlug = 'requests' const eventName = `meter-${projectId}-${pricingPlanLineItemSlug}` const identifier = requestId ? `${requestId}:${consumer.id}:${tool?.name || 'unknown-tool'}` : undefined // Report usage to Stripe asynchronously. waitUntil( stripe.billing.meterEvents.create({ event_name: eventName, identifier, payload: { value: numRequestsCost.toString(), stripe_customer_id: consumer._stripeCustomerId } }) ) } } ================================================ FILE: apps/gateway/src/lib/reset.d.ts ================================================ import '@fisch0920/config/ts-reset' ================================================ FILE: apps/gateway/src/lib/resolve-edge-request.ts ================================================ import type { AdminDeployment, PricingPlan } from '@agentic/platform-types' import { assert } from '@agentic/platform-core' import { parseToolIdentifier } from '@agentic/platform-validators' import type { AdminConsumer, GatewayHonoContext, ResolvedEdgeRequest } from './types' import { getAdminConsumer } from './get-admin-consumer' import { getAdminDeployment } from './get-admin-deployment' /** * Resolves an input HTTP request to a specific deployment and edge request * mode (MCP or HTTP). */ export async function resolveEdgeRequest( ctx: GatewayHonoContext ): Promise { const requestUrl = new URL(ctx.req.url) const { pathname } = requestUrl const requestedToolIdentifier = pathname.replace(/^\//, '').replace(/\/$/, '') const parsedToolIdentifier = parseToolIdentifier(requestedToolIdentifier) const deployment = await getAdminDeployment( ctx, parsedToolIdentifier.deploymentIdentifier ) const edgeRequestMode = parsedToolIdentifier.toolName === 'mcp' ? 'MCP' : 'HTTP' return { edgeRequestMode, parsedToolIdentifier, deployment, requestId: ctx.get('requestId'), ip: ctx.get('ip') } } /** * Resolves a consumer and pricing plan for an edge request. */ export async function resolveConsumerForEdgeRequest( ctx: GatewayHonoContext, { deployment, apiKey }: { deployment: AdminDeployment apiKey?: string } ): Promise<{ consumer?: AdminConsumer pricingPlan?: PricingPlan }> { let pricingPlan: PricingPlan | undefined let consumer: AdminConsumer | undefined if (apiKey) { consumer = await getAdminConsumer(ctx, apiKey) assert(consumer, 401, `Invalid API key "${apiKey}"`) assert( consumer.isStripeSubscriptionActive, 402, `API key "${apiKey}" does not have an active subscription` ) assert( consumer.projectId === deployment.projectId, 403, `API key "${apiKey}" is not authorized for project "${deployment.projectId}"` ) // TODO: Ensure that consumer.plan is compatible with the target deployment? // TODO: This could definitely cause issues when changing pricing plans. pricingPlan = deployment.pricingPlans.find( (pricingPlan) => consumer!.plan === pricingPlan.slug ) // assert( // pricingPlan, // 403, // `Auth token "${token}" unable to find matching pricing plan for project "${deployment.project}"` // ) } else { // For unauthenticated requests, default to a free pricing plan if available. pricingPlan = deployment.pricingPlans.find((plan) => plan.slug === 'free') } return { consumer, pricingPlan } } ================================================ FILE: apps/gateway/src/lib/resolve-http-edge-request.ts ================================================ import { assert } from '@agentic/platform-core' import type { GatewayHonoContext, ResolvedEdgeRequest, ResolvedHttpEdgeRequest } from './types' import { getTool } from './get-tool' import { getToolArgsFromRequest } from './get-tool-args-from-request' import { resolveConsumerForEdgeRequest } from './resolve-edge-request' import { isRequestPubliclyCacheable } from './utils' /** * Resolves an input HTTP request to a specific deployment, tool call, consumer, * and pricing plan. */ export async function resolveHttpEdgeRequest( ctx: GatewayHonoContext, resolvedEdgeRequest: ResolvedEdgeRequest ): Promise { assert( resolvedEdgeRequest.edgeRequestMode === 'HTTP', 500, `Internal error: Invalid edge request mode "${resolvedEdgeRequest.edgeRequestMode}" (expected "HTTP")` ) const logger = ctx.get('logger') const ip = ctx.get('ip') const cacheControl = isRequestPubliclyCacheable(ctx.req.raw) ? ctx.req.header('cache-control') : 'no-store' const { deployment, parsedToolIdentifier } = resolvedEdgeRequest const { toolName } = parsedToolIdentifier const { method } = ctx.req const tool = getTool({ method, deployment, toolName }) logger.debug('request', { method, deploymentIdentifier: deployment.identifier, toolName, tool }) const apiKey = (ctx.req.header('authorization') || '') .replace(/^Bearer /i, '') .trim() const { consumer, pricingPlan } = await resolveConsumerForEdgeRequest(ctx, { deployment, apiKey }) if (consumer) { if (!ctx.get('sessionId')) { ctx.set('sessionId', `${consumer.id}:${deployment.id}`) } } else { if (!ctx.get('sessionId')) { assert(ip, 500, 'IP address is required for unauthenticated requests') ctx.set('sessionId', `${ip}:${deployment.projectId}`) } } // Parse tool call arguments from the request body. const toolCallArgs = await getToolArgsFromRequest(ctx, { tool, deployment }) return { ...resolvedEdgeRequest, edgeRequestMode: 'HTTP', consumer, pricingPlan, tool, toolCallArgs, cacheControl } } ================================================ FILE: apps/gateway/src/lib/resolve-mcp-edge-request.ts ================================================ import { assert } from '@agentic/platform-core' import type { GatewayHonoContext, ResolvedEdgeRequest, ResolvedMcpEdgeRequest } from './types' import { resolveConsumerForEdgeRequest } from './resolve-edge-request' export async function resolveMcpEdgeRequest( ctx: GatewayHonoContext, resolvedEdgeRequest: ResolvedEdgeRequest ): Promise { assert( resolvedEdgeRequest.edgeRequestMode === 'MCP', 500, `Internal error: Invalid edge request mode "${resolvedEdgeRequest.edgeRequestMode}" (expected "MCP")` ) const { deployment } = resolvedEdgeRequest // TODO: Should MCP edge requests also support Authorization header? const apiKey = ctx.req.query('apiKey')?.trim() const { consumer, pricingPlan } = await resolveConsumerForEdgeRequest(ctx, { deployment, apiKey }) return { ...resolvedEdgeRequest, edgeRequestMode: 'MCP', consumer, pricingPlan } } ================================================ FILE: apps/gateway/src/lib/resolve-origin-tool-call.ts ================================================ import type { AdminDeployment, AgenticMcpRequestMetadata, PricingPlan, Tool } from '@agentic/platform-types' import type { DurableObjectStub } from '@cloudflare/workers-types' import { assert, RateLimitError } from '@agentic/platform-core' import { parseDeploymentIdentifier } from '@agentic/platform-validators' import type { DurableMcpClientBase } from './durable-mcp-client' import type { RawEnv } from './env' import type { AdminConsumer, CacheStatus, McpToolCallResponse, RateLimitResult, ResolvedOriginToolCallResult, ToolCallArgs, WaitUntil } from './types' import { cfValidateJsonSchema } from './cf-validate-json-schema' import { createHttpRequestForOpenAPIOperation } from './create-http-request-for-openapi-operation' import { fetchCache } from './fetch-cache' import { getRequestCacheKey } from './get-request-cache-key' import { enforceRateLimit } from './rate-limits/enforce-rate-limit' import { updateOriginRequest } from './update-origin-request' import { isCacheControlPubliclyCacheable, isResponsePubliclyCacheable } from './utils' export async function resolveOriginToolCall({ tool, args, deployment, consumer, pricingPlan, ip, sessionId, env, cacheControl, waitUntil }: { tool: Tool args?: ToolCallArgs deployment: AdminDeployment consumer?: AdminConsumer pricingPlan?: PricingPlan ip?: string sessionId: string env: RawEnv cacheControl?: string waitUntil: WaitUntil }): Promise { // TODO: consider moving all of this per-request logic to a diff method since // it's not specific to tool calls. eg, other MCP requests may still need to // be rate-limited / cached / tracked / etc. const { origin } = deployment // TODO: make this configurable via `ToolConfig.cost` const numRequestsCost = 1 let rateLimit = deployment.defaultRateLimit let rateLimitResult: RateLimitResult | undefined let cacheStatus: CacheStatus | undefined let reportUsage = true // Resolve rate limit and whether to report `requests` usage based on the // customer's pricing plan and deployment config. if (pricingPlan) { if (pricingPlan.rateLimit) { rateLimit = pricingPlan.rateLimit } const requestsLineItem = pricingPlan.lineItems.find( (lineItem) => lineItem.slug === 'requests' ) if (!requestsLineItem) { // No `requests` line-item, so we don't report usage for this tool. reportUsage = false } } const toolConfig = deployment.toolConfigs.find( (toolConfig) => toolConfig.name === tool.name ) if (toolConfig) { if (toolConfig.reportUsage !== undefined) { reportUsage &&= !!toolConfig.reportUsage } if (toolConfig.rateLimit !== undefined) { rateLimit = toolConfig.rateLimit } if (cacheControl) { if (!isCacheControlPubliclyCacheable(cacheControl)) { // Incoming request explicitly requests to bypass the gateway's cache. cacheStatus = 'BYPASS' } else { // TODO: Should we allow incoming cache-control headers to override the // gateway's cache behavior? } } else { // If the incoming request doesn't specify a desired `cache-control`, // then use a default based on the tool's configured settings. if (toolConfig.cacheControl !== undefined) { cacheControl = toolConfig.cacheControl } else if (toolConfig.pure) { // If the tool is marked as `pure`, then we can cache responses in our // public, shared cache indefinitely. cacheControl = 'public, max-age=31560000, s-maxage=31560000, stale-while-revalidate=3600' } else { // Default to not caching any responses. cacheControl = 'no-store' cacheStatus = 'DYNAMIC' } } const pricingPlanToolOverride = pricingPlan ? toolConfig.pricingPlanOverridesMap?.[pricingPlan.slug] : undefined const isToolEnabled = toolConfig.enabled ?? true // Check if this tool is configured for pricing-plan-specific overrides // which take precedence over the tool's default behavior. if (pricingPlan && pricingPlanToolOverride) { if (pricingPlanToolOverride.enabled !== undefined) { assert( pricingPlanToolOverride.enabled, isToolEnabled ? 403 : 404, `Tool "${tool.name}" is disabled for pricing plan "${pricingPlan.slug}"` ) } else { assert(isToolEnabled, 404, `Tool "${tool.name}" is disabled`) } if (pricingPlanToolOverride.reportUsage !== undefined) { reportUsage &&= !!pricingPlanToolOverride.reportUsage } if (pricingPlanToolOverride.rateLimit !== undefined) { rateLimit = pricingPlanToolOverride.rateLimit } } else { assert(isToolEnabled, 404, `Tool "${tool.name}" is disabled`) } } else { if (cacheControl) { if (!isCacheControlPubliclyCacheable(cacheControl)) { // Incoming request explicitly requests to bypass the gateway's cache. cacheStatus = 'BYPASS' } else { // TODO: Should we allow incoming cache-control headers to override the // gateway's cache behavior? } } else { // Default to not caching any responses. cacheControl = 'no-store' cacheStatus = 'DYNAMIC' } } if (rateLimit) { // TODO: Consider decrementing rate limit if the response is cached or // errors? this doesn't seem too important, so will leave as-is for now. rateLimitResult = await enforceRateLimit({ rateLimit, id: consumer?.id ?? ip ?? sessionId, cost: numRequestsCost, env, waitUntil }) if (rateLimitResult && !rateLimitResult.passed) { throw new RateLimitError({ rateLimitResult }) } } if (origin.type === 'raw') { // TODO assert(false, 500, 'Raw origin adapter not implemented') } else { // Validate incoming request params against the tool's input schema. const toolCallArgs = cfValidateJsonSchema>({ schema: tool.inputSchema, data: args, errorPrefix: `Invalid request parameters for tool "${tool.name}"`, strictAdditionalProperties: toolConfig?.inputSchemaAdditionalProperties === false }) const originStartTimeMs = Date.now() if (origin.type === 'openapi') { const operation = origin.toolToOperationMap[tool.name] assert(operation, 404, `Tool "${tool.name}" not found in OpenAPI spec`) assert(toolCallArgs, 500) const originRequest = await createHttpRequestForOpenAPIOperation({ toolCallArgs, operation, deployment }) updateOriginRequest(originRequest, { consumer, deployment, cacheControl }) const cacheKey = await getRequestCacheKey(originRequest) // TODO: transform origin 5XX errors to 502 errors... const originResponse = await fetchCache({ cacheKey, fetchResponse: async () => { let response = await fetch(originRequest) if (cacheControl && isResponsePubliclyCacheable(response)) { response = new Response(response.body, response) response.headers.set('cache-control', cacheControl) } return response }, waitUntil }) // Fetch the origin response without caching (useful for debugging) // const originResponse = await fetch(originRequest) cacheStatus = (originResponse.headers.get('cf-cache-status') as CacheStatus) ?? cacheStatus ?? (cacheKey ? 'MISS' : 'BYPASS') return { cacheStatus, reportUsage, rateLimit, rateLimitResult, toolCallArgs, originRequest, originResponse, originTimespanMs: Date.now() - originStartTimeMs, numRequestsCost, toolConfig } } else if (origin.type === 'mcp') { const { projectIdentifier } = parseDeploymentIdentifier( deployment.identifier, { errorStatusCode: 500 } ) const id = env.DO_MCP_CLIENT.idFromName(sessionId) const originMcpClient = env.DO_MCP_CLIENT.get( id ) as DurableObjectStub await originMcpClient.init({ url: deployment.origin.url, name: origin.serverInfo.name, version: origin.serverInfo.version, headers: origin.headers }) const originMcpRequestMetadata = { agenticProxySecret: deployment._secret, sessionId, ip, isCustomerSubscriptionActive: !!consumer?.isStripeSubscriptionActive, customerId: consumer?.id, customerSubscriptionPlan: consumer?.plan, customerSubscriptionStatus: consumer?.stripeStatus, userId: consumer?.user.id, userEmail: consumer?.user.email, userUsername: consumer?.user.username, userName: consumer?.user.name, userCreatedAt: consumer?.user.createdAt, userUpdatedAt: consumer?.user.updatedAt, deploymentId: deployment.id, deploymentIdentifier: deployment.identifier, projectId: deployment.projectId, projectIdentifier } as AgenticMcpRequestMetadata let cacheKey: Request | undefined if (cacheControl && isCacheControlPubliclyCacheable(cacheControl)) { const fakeOriginRequest = new Request(deployment.origin.url, { method: 'POST', headers: { 'content-type': 'application/json', 'cache-control': cacheControl }, body: JSON.stringify({ name: tool.name, args: toolCallArgs, metadata: originMcpRequestMetadata }) }) cacheKey = await getRequestCacheKey(fakeOriginRequest) if (cacheKey) { const response = await caches.default.match(cacheKey) if (response) { return { cacheStatus: 'HIT', reportUsage, rateLimit, rateLimitResult, toolCallArgs, toolCallResponse: (await response.json()) as McpToolCallResponse, originTimespanMs: Date.now() - originStartTimeMs, numRequestsCost, toolConfig } } } } // TODO: add timeout support to the origin tool call? const toolCallResponseString = await originMcpClient.callTool({ name: tool.name, args: toolCallArgs, metadata: originMcpRequestMetadata }) const toolCallResponse = JSON.parse( toolCallResponseString ) as McpToolCallResponse if (cacheControl && cacheKey) { const fakeHttpResponse = new Response(toolCallResponseString, { headers: { 'content-type': 'application/json', 'cache-control': cacheControl } }) waitUntil(caches.default.put(cacheKey, fakeHttpResponse)) } return { cacheStatus: cacheStatus ?? (cacheKey ? 'MISS' : 'BYPASS'), reportUsage, rateLimit, rateLimitResult, toolCallArgs, toolCallResponse, originTimespanMs: Date.now() - originStartTimeMs, numRequestsCost, toolConfig } } else { assert( false, 500, `Internal error: origin adapter type "${(origin as any).type}"` ) } } } ================================================ FILE: apps/gateway/src/lib/temp ================================================ // import type { AdminDeployment, PricingPlan } from '@agentic/platform-types' // import type { JSONRPCRequest } from '@modelcontextprotocol/sdk/types.js' // // import type { JSONRPCRequest } from '@modelcontextprotocol/sdk/types.js' // import { assert } from '@agentic/platform-core' // import { parseDeploymentIdentifier } from '@agentic/platform-validators' // import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js' // import { StreamableHTTPServerTransport } from '@modelcontextprotocol/sdk/server/streamableHttp.js' // import { DurableObject } from 'cloudflare:workers' // import type { AdminConsumer } from './types' // export type DurableMcpServerInfo = { // deployment: AdminDeployment // consumer?: AdminConsumer // pricingPlan?: PricingPlan // } // export class DurableMcpServer extends DurableObject { // protected server?: McpServer // protected serverTransport?: StreamableHTTPServerTransport // protected serverConnectionP?: Promise // async init(mcpServerInfo: DurableMcpServerInfo) { // const existingMcpServerInfo = // await this.ctx.storage.get('mcp-server-info') // if (!existingMcpServerInfo) { // await this.ctx.storage.put('mcp-server-info', mcpServerInfo) // } else { // assert( // mcpServerInfo.deployment.id === existingMcpServerInfo.deployment.id, // 500, // `DurableMcpServerInfo deployment id mismatch: "${mcpServerInfo.deployment.id}" vs "${existingMcpServerInfo.deployment.id}"` // ) // } // return this.ensureServerConnection(mcpServerInfo) // } // async isInitialized(): Promise { // return !!(await this.ctx.storage.get('mcp-server-info')) // } // async ensureServerConnection(mcpServerInfo?: DurableMcpServerInfo) { // if (this.serverConnectionP) return this.serverConnectionP // mcpServerInfo ??= // await this.ctx.storage.get('mcp-server-info') // assert(mcpServerInfo, 500, 'DurableMcpServer has not been initialized') // const { deployment } = mcpServerInfo // const { projectIdentifier } = parseDeploymentIdentifier( // deployment.identifier // ) // this.server = new McpServer({ // name: projectIdentifier, // version: deployment.version ?? '0.0.0' // }) // for (const tool of deployment.tools) { // this.server.registerTool( // tool.name, // { // description: tool.description, // inputSchema: tool.inputSchema as any, // TODO: investigate types // outputSchema: tool.outputSchema as any, // TODO: investigate types // annotations: tool.annotations // }, // (_args: Record) => { // assert(false, 500, `Tool call not implemented: ${tool.name}`) // // TODO??? // return { // content: [], // _meta: { // toolName: tool.name // } // } // } // ) // } // const transport = new StreamableHTTPServerTransport({ // sessionIdGenerator: () => { // // TODO: improve this // return crypto.randomUUID() // }, // onsessioninitialized: (sessionId) => { // // TODO: improve this // // eslint-disable-next-line no-console // console.log(`Session initialized: ${sessionId}`) // } // }) // this.serverConnectionP = this.server.connect(transport) // return this.serverConnectionP // } // // async fetch(request: Request) { // // await this.ensureServerConnection() // // const { readable, writable } = new TransformStream() // // const writer = writable.getWriter() // // const encoder = new TextEncoder() // // const response = new Response(readable, { // // headers: { // // 'Content-Type': 'text/event-stream', // // 'Cache-Control': 'no-cache', // // Connection: 'keep-alive' // // // 'mcp-session-id': sessionId // // } // // }) // // await this.serverTransport!.handleRequest(request, response) // // } // async onRequest(message: JSONRPCRequest) { // await this.ensureServerConnection() // // We need to map every incoming message to the connection that it came in on // // so that we can send relevant responses and notifications back on the same connection // // if (isJSONRPCRequest(message)) { // // this._requestIdToConnectionId.set(message.id.toString(), connection.id); // // } // this.serverTransport!.onmessage?.(message) // } // } ================================================ FILE: apps/gateway/src/lib/temp-mcp ================================================ // import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js' // import { StreamableHTTPClientTransport } from '@modelcontextprotocol/sdk/client/streamableHttp.js' // import type { Transport } from '@modelcontextprotocol/sdk/shared/transport.js' import { assert, JsonRpcError } from '@agentic/platform-core' import { StreamableHTTPServerTransport } from '@modelcontextprotocol/sdk/server/streamableHttp.js' import { InitializeRequestSchema, isJSONRPCError, isJSONRPCNotification, isJSONRPCRequest, isJSONRPCResponse, type JSONRPCMessage, JSONRPCMessageSchema } from '@modelcontextprotocol/sdk/types.js' import type { GatewayHonoContext } from './lib/types' import { createConsumerMcpServer } from './lib/consumer-mcp-server' import { resolveMcpEdgeRequest } from './lib/resolve-mcp-edge-request' // import { DurableMcpServer } from './lib/durable-mcp-server' // TODO: https://github.com/modelcontextprotocol/servers/blob/8fb7bbdab73eddb42aba72e8eab81102efe1d544/src/everything/sse.ts // TODO: https://github.com/cloudflare/agents // const transports: Map = new Map< // string, // StreamableHTTPClientTransport // >() // const server = new McpServer({ // name: 'weather', // version: '1.0.0', // capabilities: { // resources: {}, // tools: {} // } // }) // class McpStreamableHttpTransport implements Transport { // onclose?: () => void // onerror?: (error: Error) => void // onmessage?: (message: JSONRPCMessage) => void // sessionId?: string // // TODO: If there is an open connection to send server-initiated messages // // back, we should use that connection // private _getWebSocketForGetRequest: () => WebSocket | null // // Get the appropriate websocket connection for a given message id // private _getWebSocketForMessageID: (id: string) => WebSocket | null // // Notify the server that a response has been sent for a given message id // // so that it may clean up it's mapping of message ids to connections // // once they are no longer needed // private _notifyResponseIdSent: (id: string) => void // private _started = false // constructor( // getWebSocketForMessageID: (id: string) => WebSocket | null, // notifyResponseIdSent: (id: string | number) => void // ) { // this._getWebSocketForMessageID = getWebSocketForMessageID // this._notifyResponseIdSent = notifyResponseIdSent // // TODO // this._getWebSocketForGetRequest = () => null // } // async start() { // // The transport does not manage the WebSocket connection since it's terminated // // by the Durable Object in order to allow hibernation. There's nothing to initialize. // if (this._started) { // throw new Error('Transport already started') // } // this._started = true // } // async send(message: JSONRPCMessage) { // if (!this._started) { // throw new Error('Transport not started') // } // let websocket: WebSocket | null = null // if (isJSONRPCResponse(message) || isJSONRPCError(message)) { // websocket = this._getWebSocketForMessageID(message.id.toString()) // if (!websocket) { // throw new Error( // `Could not find WebSocket for message id: ${message.id}` // ) // } // } else if (isJSONRPCRequest(message)) { // // requests originating from the server must be sent over the // // the connection created by a GET request // websocket = this._getWebSocketForGetRequest() // } else if (isJSONRPCNotification(message)) { // // notifications do not have an id // // but do have a relatedRequestId field // // so that they can be sent to the correct connection // websocket = null // } // try { // websocket?.send(JSON.stringify(message)) // if (isJSONRPCResponse(message)) { // this._notifyResponseIdSent(message.id.toString()) // } // } catch (err) { // this.onerror?.(err as Error) // throw err // } // } // async close() { // // Similar to start, the only thing to do is to pass the event on to the server // this.onclose?.() // } // } const MAXIMUM_MESSAGE_SIZE_BYTES = 4 * 1024 * 1024 // 4MB export async function handleMcpRequest(ctx: GatewayHonoContext) { const request = ctx.req.raw ctx.set('isJsonRpcRequest', true) if (request.method !== 'POST') { // We don't yet support GET or DELETE requests throw new JsonRpcError({ message: 'Method not allowed', statusCode: 405, jsonRpcErrorCode: -32_000, jsonRpcId: null }) } // validate the Accept header const acceptHeader = request.headers.get('accept') // The client MUST include an Accept header, listing both application/json and text/event-stream as supported content types. if ( !acceptHeader?.includes('application/json') || !acceptHeader.includes('text/event-stream') ) { throw new JsonRpcError({ message: 'Not Acceptable: Client must accept both "application/json" and "text/event-stream"', statusCode: 406, jsonRpcErrorCode: -32_000, jsonRpcId: null }) } const ct = request.headers.get('content-type') if (!ct?.includes('application/json')) { throw new JsonRpcError({ message: 'Unsupported Media Type: Content-Type must be "application/json"', statusCode: 415, jsonRpcErrorCode: -32_000, jsonRpcId: null }) } // Check content length against maximum allowed size const contentLength = Number.parseInt( request.headers.get('content-length') ?? '0', 10 ) if (contentLength > MAXIMUM_MESSAGE_SIZE_BYTES) { throw new JsonRpcError({ message: `Request body too large. Maximum size is ${MAXIMUM_MESSAGE_SIZE_BYTES} bytes`, statusCode: 413, jsonRpcErrorCode: -32_000, jsonRpcId: null }) } let sessionId = request.headers.get('mcp-session-id') let rawMessage: unknown try { rawMessage = await request.json() } catch { throw new JsonRpcError({ message: 'Parse error: Invalid JSON', statusCode: 400, jsonRpcErrorCode: -32_700, jsonRpcId: null }) } // Make sure the message is an array to simplify logic const rawMessages = Array.isArray(rawMessage) ? rawMessage : [rawMessage] // Try to parse each message as JSON RPC. Fail if any message is invalid const messages: JSONRPCMessage[] = rawMessages.map((msg) => { const parsed = JSONRPCMessageSchema.safeParse(msg) if (!parsed.success) { throw new JsonRpcError({ message: 'Parse error: Invalid JSON-RPC message', statusCode: 400, jsonRpcErrorCode: -32_700, jsonRpcId: null }) } return parsed.data }) // Before we pass the messages to the agent, there's another error condition // we need to enforce. Check if this is an initialization request // https://spec.modelcontextprotocol.io/specification/2025-03-26/basic/lifecycle/ const isInitializationRequest = messages.some( (msg) => InitializeRequestSchema.safeParse(msg).success ) if (isInitializationRequest && sessionId) { throw new JsonRpcError({ message: 'Invalid Request: Initialization requests must not include a sessionId', statusCode: 400, jsonRpcErrorCode: -32_600, jsonRpcId: null }) } // The initialization request must be the only request in the batch if (isInitializationRequest && messages.length > 1) { throw new JsonRpcError({ message: 'Invalid Request: Only one initialization request is allowed', statusCode: 400, jsonRpcErrorCode: -32_600, jsonRpcId: null }) } // If an Mcp-Session-Id is returned by the server during initialization, // clients using the Streamable HTTP transport MUST include it // in the Mcp-Session-Id header on all of their subsequent HTTP requests. if (!isInitializationRequest && !sessionId) { throw new JsonRpcError({ message: 'Bad Request: Mcp-Session-Id header is required', statusCode: 400, jsonRpcErrorCode: -32_000, jsonRpcId: null }) } // If we don't have a sessionId, we are serving an initialization request // and need to generate a new sessionId sessionId = sessionId ?? ctx.env.DO_MCP_SERVER.newUniqueId().toString() assert( !ctx.get('sessionId'), 500, 'Session ID should be set by MCP handler for MCP edge requests' ) ctx.set('sessionId', sessionId) // TODO: first version using the McpServer locally instead of a DurableMcpServer // Fetch the durable mcp server for this session // const id = ctx.env.DO_MCP_SERVER.idFromName(`streamable-http:${sessionId}`) // const durableMcpServer = ctx.env.DO_MCP_SERVER.get(id) // const isInitialized = await durableMcpServer.isInitialized() // if (!isInitializationRequest && !isInitialized) { // // A session id that was never initialized was provided // throw new JsonRpcError({ // message: 'Session not found', // statusCode: 404, // jsonRpcErrorCode: -32_001, // jsonRpcId: null // }) // } const { deployment, consumer, pricingPlan } = await resolveMcpEdgeRequest(ctx) const server = createConsumerMcpServer(ctx, { sessionId, deployment, consumer, pricingPlan }) const transport = new StreamableHTTPServerTransport({ sessionIdGenerator: () => { return ctx.env.DO_MCP_SERVER.newUniqueId().toString() }, onsessioninitialized: (sessionId) => { // TODO: improve this // eslint-disable-next-line no-console console.log(`Session initialized: ${sessionId}`) } }) await server.connect(transport) // if (isInitializationRequest) { // await durableMcpServer.init({ // deployment, // consumer, // pricingPlan // }) // } // We've validated and initialized the request! Now it's time to actually // handle the JSON RPC messages in the request and respond with an SSE // stream. // Create a Transform Stream for SSE const { readable, writable } = new TransformStream() const writer = writable.getWriter() const encoder = new TextEncoder() // Keep track of the request ids that we have sent to the server // so that we can close the connection once we have received // all the responses const requestIds = new Set() // eslint-disable-next-line unicorn/prefer-add-event-listener transport.onmessage = async (message) => { // eslint-disable-next-line no-console console.log('onmessage', message) // validate that the message is a valid JSONRPC message const result = JSONRPCMessageSchema.safeParse(message) if (!result.success) { // TODO: add a warning here return } // If the message is a response or an error, remove the id from the set of // request ids if (isJSONRPCResponse(result.data) || isJSONRPCError(result.data)) { requestIds.delete(result.data.id) } // Send the message as an SSE event const messageText = `event: message\ndata: ${JSON.stringify(result.data)}\n\n` await writer.write(encoder.encode(messageText)) // If we have received all the responses, close the connection if (!requestIds.size) { ctx.executionCtx.waitUntil(transport.close()) await writer.close() } } // If there are no requests, we send the messages downstream and // acknowledge the request with a 202 since we don't expect any responses // back through this connection. const hasOnlyNotificationsOrResponses = messages.every( (msg) => isJSONRPCNotification(msg) || isJSONRPCResponse(msg) ) if (hasOnlyNotificationsOrResponses) { await Promise.all(messages.map((message) => transport.send(message))) return new Response(null, { status: 202 }) } for (const message of messages) { if (isJSONRPCRequest(message)) { // Add each request id that we send off to a set so that we can keep // track of which requests we still need a response for. requestIds.add(message.id) } await transport.send(message) } // console.log('>>> waiting...') // await new Promise((resolve) => setTimeout(resolve, 2000)) // console.log('<<< waiting...') // Return the streamable http response. return new Response(readable, { headers: { 'Content-Type': 'text/event-stream', 'Cache-Control': 'no-cache', Connection: 'keep-alive', 'mcp-session-id': sessionId }, status: 200 }) } ================================================ FILE: apps/gateway/src/lib/transform-http-response-to-mcp-tool-call-response.ts ================================================ import type { Tool, ToolConfig } from '@agentic/platform-types' import { assert, HttpError } from '@agentic/platform-core' import contentType from 'fast-content-type-parse' import type { McpToolCallResponse, ToolCallArgs } from './types' import { cfValidateJsonSchema } from './cf-validate-json-schema' export async function transformHttpResponseToMcpToolCallResponse({ originRequest, originResponse, tool, toolCallArgs, toolConfig }: { originRequest: Request originResponse: Response tool: Tool toolCallArgs: ToolCallArgs toolConfig?: ToolConfig }) { const { type: mimeType } = contentType.safeParse( originResponse.headers.get('content-type') || 'application/octet-stream' ) // TODO: move these logs should be higher up // eslint-disable-next-line no-console console.log('httpOriginResponse', { tool: tool.name, toolCallArgs, url: originRequest.url, method: originRequest.method, originResponse: { mimeType, status: originResponse.status // headers: Object.fromEntries(originResponse.headers.entries()) } }) if (originResponse.status >= 400) { let message = originResponse.statusText try { message = await originResponse.text() } catch {} // eslint-disable-next-line no-console console.error('httpOriginResponse ERROR', { tool: tool.name, toolCallArgs, url: originRequest.url, method: originRequest.method, originResponse: { mimeType, status: originResponse.status, // headers: Object.fromEntries(originResponse.headers.entries()), message } }) throw new HttpError({ statusCode: originResponse.status, message, cause: originResponse }) } const result: McpToolCallResponse = { isError: originResponse.status >= 400 } if (tool.outputSchema) { assert( mimeType.includes('json'), 502, `Tool "${tool.name}" requires a JSON response, but the origin returned content type "${mimeType}"` ) const data = (await originResponse.json()) as Record const toolCallResponseContent = cfValidateJsonSchema({ data, schema: tool.outputSchema, coerce: false, strictAdditionalProperties: toolConfig?.outputSchemaAdditionalProperties === false, errorPrefix: `Invalid tool response for tool "${tool.name}"`, errorStatusCode: 502 }) result.structuredContent = toolCallResponseContent } else { if (mimeType.includes('json')) { result.structuredContent = await originResponse.json() } else if (mimeType.includes('text')) { result.content = [ { type: 'text', text: await originResponse.text() } ] } else { const resBody = await originResponse.arrayBuffer() const resBodyBase64 = Buffer.from(resBody).toString('base64') const type = mimeType.includes('image') ? 'image' : mimeType.includes('audio') ? 'audio' : 'resource' // TODO: this needs work result.content = [ { type, mimeType, ...(type === 'resource' ? { blob: resBodyBase64 } : { data: resBodyBase64 }) } ] } } return result } ================================================ FILE: apps/gateway/src/lib/types.ts ================================================ import type { AgenticApiClient } from '@agentic/platform-api-client' import type { RateLimitResult } from '@agentic/platform-core' import type { DefaultHonoBindings, DefaultHonoVariables } from '@agentic/platform-hono' import type { AdminConsumer as AdminConsumerImpl, AdminDeployment, PricingPlan, RateLimit, Tool, ToolConfig, User } from '@agentic/platform-types' import type { ParsedToolIdentifier } from '@agentic/platform-validators' import type { Client as McpClient } from '@modelcontextprotocol/sdk/client/index.js' import type { Context } from 'hono' import type { Simplify } from 'type-fest' import type { Env } from './env' export type { RateLimitResult } from '@agentic/platform-core' export type McpToolCallResponse = Simplify< Awaited> > export type AdminConsumer = Simplify< AdminConsumerImpl & { user: User } > export type GatewayHonoVariables = Simplify< DefaultHonoVariables & { client: AgenticApiClient cache: Cache sessionId?: string reportUsage?: boolean } > export type GatewayHonoBindings = Simplify export type GatewayHonoEnv = { Bindings: GatewayHonoBindings Variables: GatewayHonoVariables } export type GatewayHonoContext = Context // TODO: better type here export type ToolCallArgs = Record export type RateLimitState = { current: number resetTimeMs: number } export type RateLimitCache = Map export type CacheStatus = 'HIT' | 'MISS' | 'BYPASS' | 'DYNAMIC' export type EdgeRequestMode = 'MCP' | 'HTTP' export type WaitUntil = (promise: Promise) => void export interface ResolvedEdgeRequest extends Record { edgeRequestMode: EdgeRequestMode parsedToolIdentifier: ParsedToolIdentifier deployment: AdminDeployment requestId: string ip?: string } export interface ResolvedMcpEdgeRequest extends ResolvedEdgeRequest { edgeRequestMode: 'MCP' consumer?: AdminConsumer pricingPlan?: PricingPlan } export interface ResolvedHttpEdgeRequest extends ResolvedEdgeRequest { edgeRequestMode: 'HTTP' consumer?: AdminConsumer pricingPlan?: PricingPlan tool: Tool toolCallArgs: ToolCallArgs cacheControl?: string } export type ResolvedOriginToolCallResult = { toolCallArgs: ToolCallArgs originRequest?: Request originResponse?: Response toolCallResponse?: McpToolCallResponse rateLimit?: RateLimit rateLimitResult?: RateLimitResult cacheStatus: CacheStatus reportUsage: boolean toolConfig?: ToolConfig originTimespanMs: number numRequestsCost: number } & ( | { originRequest: Request originResponse: Response toolCallResponse?: never } | { originRequest?: never originResponse?: never toolCallResponse: McpToolCallResponse } ) ================================================ FILE: apps/gateway/src/lib/update-origin-request.ts ================================================ import type { AdminDeployment } from '@agentic/platform-types' import type { AdminConsumer } from './types' // TODO: support custom auth providers // const authProviders = ['github', 'google', 'spotify', 'linkedin', 'twitter'] export function updateOriginRequest( originRequest: Request, { deployment, consumer, cacheControl }: { deployment: AdminDeployment consumer?: AdminConsumer cacheControl?: string } ) { // originRequest.headers.delete('authorization') // for (const provider of authProviders) { // const headerAccessToken = `x-${provider}-access-token` // const headerRefreshToken = `x-${provider}-refresh-token` // const headerAccessTokenSecret = `x-${provider}-access-token-secret` // const headerId = `x-${provider}-id` // const headerUsername = `x-${provider}-username` // originRequest.headers.delete(headerAccessToken) // originRequest.headers.delete(headerRefreshToken) // originRequest.headers.delete(headerAccessTokenSecret) // originRequest.headers.delete(headerId) // originRequest.headers.delete(headerUsername) // } // Delete all Cloudflare headers since we want origin requests to be agnostic // to Agentic's choice of hosting provider. for (const headerKey of Object.keys( Object.fromEntries(originRequest.headers.entries()) )) { if (headerKey.startsWith('cf-')) { originRequest.headers.delete(headerKey) } } originRequest.headers.delete('x-agentic-consumer') originRequest.headers.delete('x-agentic-user') originRequest.headers.delete('x-agentic-plan') originRequest.headers.delete('x-agentic-is-subscription-active') originRequest.headers.delete('x-agentic-subscription-status') originRequest.headers.delete('x-agentic-user-email') originRequest.headers.delete('x-agentic-user-username') originRequest.headers.delete('x-agentic-user-name') originRequest.headers.delete('x-agentic-user-created-at') originRequest.headers.delete('x-forwarded-for') if (consumer) { originRequest.headers.set('x-agentic-customer-id', consumer.id) originRequest.headers.set( 'x-agentic-is-customer-subscription-active', consumer.isStripeSubscriptionActive.toString() ) originRequest.headers.set( 'x-agentic-customer-subscription-status', consumer.stripeStatus ) originRequest.headers.set('x-agentic-user-id', consumer.user.id) originRequest.headers.set('x-agentic-user-email', consumer.user.email) originRequest.headers.set('x-agentic-user-username', consumer.user.username) originRequest.headers.set( 'x-agentic-user-created-at', consumer.user.createdAt ) originRequest.headers.set( 'x-agentic-user-updated-at', consumer.user.updatedAt ) if (consumer.plan) { originRequest.headers.set( 'x-agentic-customer-subscription-plan', consumer.plan ) } if (consumer.user.name) { originRequest.headers.set('x-agentic-user-name', consumer.user.name) } } else { originRequest.headers.set( 'x-agentic-is-customer-subscription-active', 'false' ) } // TODO: this header is causing some random upstream cloudflare errors // https://support.cloudflare.com/hc/en-us/articles/360029779472-Troubleshooting-Cloudflare-1XXX-errors#error1000 // originRequest.headers.set('x-forwarded-for', ip) if (cacheControl) { originRequest.headers.set('cache-control', cacheControl) } originRequest.headers.set('x-agentic-deployment-id', deployment.id) originRequest.headers.set( 'x-agentic-deployment-identifier', deployment.identifier ) originRequest.headers.set('x-agentic-proxy-secret', deployment._secret) } ================================================ FILE: apps/gateway/src/lib/utils.test.ts ================================================ import { expect, test } from 'vitest' import { createAgenticMcpMetadata, isCacheControlPubliclyCacheable, isRequestPubliclyCacheable } from './utils' test('isRequestPubliclyCacheable true', () => { expect(isRequestPubliclyCacheable(new Request('https://example.com'))).toBe( true ) expect( isRequestPubliclyCacheable( new Request('https://example.com', { headers: { 'cache-control': 'public, max-age=3600' } }) ) ).toBe(true) }) test('isRequestPubliclyCacheable false', () => { expect( isRequestPubliclyCacheable( new Request('https://example.com', { headers: { pragma: 'no-cache' } }) ) ).toBe(false) expect( isRequestPubliclyCacheable( new Request('https://example.com', { headers: { 'cache-control': 'no-store' } }) ) ).toBe(false) }) test('isCacheControlPubliclyCacheable true', () => { expect(isCacheControlPubliclyCacheable('public')).toBe(true) expect(isCacheControlPubliclyCacheable('public, max-age=3600')).toBe(true) expect(isCacheControlPubliclyCacheable('public, s-maxage=3600')).toBe(true) expect( isCacheControlPubliclyCacheable('public, max-age=3600, s-maxage=3600') ).toBe(true) expect(isCacheControlPubliclyCacheable('max-age=3600')).toBe(true) expect(isCacheControlPubliclyCacheable('s-maxage=3600')).toBe(true) expect( isCacheControlPubliclyCacheable( 'public, max-age=86400, s-maxage=86400, stale-while-revalidate=3600' ) ).toBe(true) expect(isCacheControlPubliclyCacheable('stale-while-revalidate=180')).toBe( true ) }) test('isCacheControlPubliclyCacheable false', () => { expect(isCacheControlPubliclyCacheable('no-store')).toBe(false) expect(isCacheControlPubliclyCacheable('no-cache')).toBe(false) expect(isCacheControlPubliclyCacheable('private')).toBe(false) expect(isCacheControlPubliclyCacheable('max-age=0')).toBe(false) expect(isCacheControlPubliclyCacheable('private, max-age=3600')).toBe(false) expect(isCacheControlPubliclyCacheable('private, s-maxage=3600')).toBe(false) expect( isCacheControlPubliclyCacheable('private, max-age=3600, s-maxage=3600') ).toBe(false) expect( isCacheControlPubliclyCacheable('max-age=0, must-revalidate, no-cache') ).toBe(false) expect( isCacheControlPubliclyCacheable('private, max-age=3600, must-revalidate') ).toBe(false) }) test('createAgenticMcpMetadata', () => { expect( // Test the stringified version because we want to test the order of the // keys. JSON.stringify( createAgenticMcpMetadata({ deploymentId: '123', consumerId: '456', toolName: 'test', cacheStatus: 'HIT' }) ) ).toMatchSnapshot() }) ================================================ FILE: apps/gateway/src/lib/utils.ts ================================================ import { pruneEmpty } from '@agentic/platform-core' import sortKeys from 'sort-keys' export function isRequestPubliclyCacheable(request: Request): boolean { const pragma = request.headers.get('pragma') if (pragma === 'no-cache') { return false } return isCacheControlPubliclyCacheable(request.headers.get('cache-control')) } export function isResponsePubliclyCacheable(response: Response): boolean { const pragma = response.headers.get('pragma') if (pragma === 'no-cache') { return false } return isCacheControlPubliclyCacheable(response.headers.get('cache-control')) } export function isCacheControlPubliclyCacheable( cacheControl?: string | null ): boolean { if (!cacheControl) { // TODO: should we default to true or false? return true } const directives = new Set(cacheControl.split(',').map((s) => s.trim())) if ( directives.has('no-store') || directives.has('no-cache') || directives.has('private') || directives.has('max-age=0') ) { return false } return true } const agenticMcpMetadataFieldOrder: string[] = [ 'deploymentId', 'consumerId', 'toolName', 'status', 'cacheStatus', 'headers' ] const agenticMcpMetadataFieldsOrderMap = Object.fromEntries( agenticMcpMetadataFieldOrder.map((f, i) => [f, i]) ) function agenticMcpMetadataFieldComparator(a: string, b: string): number { const aIndex = agenticMcpMetadataFieldsOrderMap[a] ?? Infinity const bIndex = agenticMcpMetadataFieldsOrderMap[b] ?? Infinity return aIndex - bIndex } /** * Sanitizes agentic MCP metadata by sorting the keys and pruning empty values. */ export function createAgenticMcpMetadata( metadata: { deploymentId: string consumerId?: string toolName?: string status?: number cacheStatus?: string headers?: Record }, existingMetadata?: Record ): Record { const rawAgenticMcpMetadata = pruneEmpty({ status: 200, ...existingMetadata?.agentic, ...metadata, headers: { ...existingMetadata?.agentic?.headers, ...metadata.headers } }) const agentic = sortKeys(rawAgenticMcpMetadata, { compare: agenticMcpMetadataFieldComparator }) return { ...existingMetadata, agentic } } ================================================ FILE: apps/gateway/src/worker.ts ================================================ import * as Sentry from '@sentry/cloudflare' import { app } from './app' import { type Env, parseEnv, type RawEnv } from './lib/env' // Export Durable Objects for cloudflare export { DurableMcpClient } from './lib/durable-mcp-client' export { DurableMcpServer } from './lib/durable-mcp-server' export { DurableRateLimiter } from './lib/rate-limits/durable-rate-limiter' // Main worker entrypoint export default Sentry.withSentry( (env: RawEnv) => ({ dsn: env.SENTRY_DSN, environment: env.ENVIRONMENT, integrations: [Sentry.extraErrorDataIntegration()], tracesSampleRate: 1.0, sendDefaultPii: true }), { async fetch( request: Request, env: Env, ctx: ExecutionContext ): Promise { let parsedEnv: Env // Validate the environment try { parsedEnv = parseEnv(env) } catch (err: any) { // eslint-disable-next-line no-console console.error('api gateway error invalid env:', err.message) return new Response( JSON.stringify({ error: 'Invalid api gateway environment' }), { status: 500, headers: { 'content-type': 'application/json' } } ) } // Handle the request with `hono` return app.fetch(request, parsedEnv, ctx) } } satisfies ExportedHandler ) ================================================ FILE: apps/gateway/tsconfig.json ================================================ { "extends": "@fisch0920/config/tsconfig-node", "compilerOptions": { "types": ["@cloudflare/workers-types"] }, "include": ["src", "*.config.ts"], "exclude": ["node_modules", "dist"] } ================================================ FILE: apps/gateway/vitest.config.ts ================================================ import { defineConfig } from 'vitest/config' export default defineConfig({ test: { environment: 'edge-runtime', globals: true, watch: false, restoreMocks: true } }) ================================================ FILE: apps/gateway/wrangler.jsonc ================================================ /** * https://developers.cloudflare.com/workers/wrangler/configuration/ */ { "$schema": "node_modules/wrangler/config-schema.json", "name": "agentic-gateway", "main": "src/worker.ts", "compatibility_date": "2025-05-25", "compatibility_flags": ["nodejs_compat"], "placement": { "mode": "smart" }, "upload_source_maps": true, "observability": { "enabled": true, "head_sampling_rate": 1 }, "migrations": [ { "tag": "v1", "new_sqlite_classes": [ "DurableRateLimiter", "DurableMcpServer", "DurableMcpClient" ] } ], "vars": { "ENVIRONMENT": "development", "AGENTIC_API_BASE_URL": "http://localhost:3001" }, "durable_objects": { "bindings": [ { "class_name": "DurableRateLimiter", "name": "DO_RATE_LIMITER" }, { "class_name": "DurableMcpServer", "name": "DO_MCP_SERVER" }, { "class_name": "DurableMcpClient", "name": "DO_MCP_CLIENT" } ] }, "analytics_engine_datasets": [ { "binding": "AE_USAGE_DATASET", "dataset": "agentic_gateway_usage" } ], "env": { "production": { "routes": [ { "pattern": "gateway.agentic.so", "custom_domain": true } ], "vars": { "ENVIRONMENT": "production", "AGENTIC_API_BASE_URL": "https://api.agentic.so" }, // TODO: double-check whether all of this needs to be duplicated for each environment "durable_objects": { "bindings": [ { "class_name": "DurableRateLimiter", "name": "DO_RATE_LIMITER" }, { "class_name": "DurableMcpServer", "name": "DO_MCP_SERVER" }, { "class_name": "DurableMcpClient", "name": "DO_MCP_CLIENT" } ] }, "analytics_engine_datasets": [ { "binding": "AE_USAGE_DATASET", "dataset": "agentic_gateway_usage" } ] } } } ================================================ FILE: apps/web/components.json ================================================ { "$schema": "https://ui.shadcn.com/schema.json", "style": "new-york", "rsc": true, "tsx": true, "tailwind": { "config": "tailwind.config.ts", "css": "src/app/globals.css", "baseColor": "neutral", "cssVariables": true, "prefix": "" }, "aliases": { "components": "@/components", "utils": "@/lib/utils", "ui": "@/components/ui", "lib": "@/lib", "hooks": "@/hooks" }, "iconLibrary": "lucide" } ================================================ FILE: apps/web/next.config.ts ================================================ import type { NextConfig } from 'next' const nextConfig: NextConfig = { // TODO: handle remote profile pictures or upload them properly on backend } export default nextConfig ================================================ FILE: apps/web/package.json ================================================ { "name": "web", "private": true, "version": "8.4.4", "description": "Agentic platform webapp.", "author": "Travis Fischer ", "license": "AGPL-3.0", "repository": { "type": "git", "url": "git+https://github.com/transitive-bullshit/agentic.git", "directory": "apps/web" }, "type": "module", "scripts": { "dev": "next dev", "build": "next build", "start": "next start", "clean": "del .next", "test": "run-s test:*", "test:lint": "next lint", "test:unit": "vitest run" }, "dependencies": { "@agentic/platform": "workspace:*", "@agentic/platform-api-client": "workspace:*", "@agentic/platform-core": "workspace:*", "@agentic/platform-types": "workspace:*", "@agentic/platform-validators": "workspace:*", "@date-fns/utc": "catalog:", "@fisch0920/markdown-to-html": "catalog:", "@number-flow/react": "catalog:", "@pmndrs/assets": "catalog:", "@radix-ui/react-avatar": "^1.1.10", "@radix-ui/react-collapsible": "catalog:", "@radix-ui/react-dropdown-menu": "catalog:", "@radix-ui/react-label": "catalog:", "@radix-ui/react-slot": "catalog:", "@radix-ui/react-tabs": "catalog:", "@radix-ui/react-tooltip": "catalog:", "@react-three/cannon": "catalog:", "@react-three/drei": "catalog:", "@react-three/fiber": "catalog:", "@react-three/postprocessing": "catalog:", "@react-three/rapier": "catalog:", "@tanstack/react-form": "catalog:", "@tanstack/react-query": "catalog:", "@tanstack/react-query-devtools": "catalog:", "@types/canvas-confetti": "catalog:", "canvas-confetti": "catalog:", "class-variance-authority": "catalog:", "clsx": "catalog:", "date-fns": "catalog:", "hast-util-to-jsx-runtime": "catalog:", "human-number": "^2.0.4", "ky": "catalog:", "lucide-react": "catalog:", "motion": "catalog:", "next": "catalog:", "next-themes": "catalog:", "plur": "catalog:", "posthog-js": "catalog:", "pretty-ms": "^9.2.0", "react": "catalog:", "react-dom": "catalog:", "react-infinite-scroll-hook": "catalog:", "react-lottie-player": "catalog:", "react-medium-image-zoom": "catalog:", "react-use": "catalog:", "server-only": "catalog:", "shiki": "catalog:", "sonner": "catalog:", "stripe": "catalog:", "suspend-react": "catalog:", "tailwind-merge": "catalog:", "three": "catalog:", "type-fest": "catalog:" }, "devDependencies": { "@tailwindcss/postcss": "catalog:", "@tailwindcss/typography": "catalog:", "@types/human-number": "^1.0.2", "@types/react": "catalog:", "@types/react-dom": "catalog:", "@types/three": "catalog:", "autoprefixer": "catalog:", "postcss": "catalog:", "tailwindcss": "catalog:", "tw-animate-css": "catalog:" } } ================================================ FILE: apps/web/postcss.config.mjs ================================================ export default { plugins: { '@tailwindcss/postcss': {} } } ================================================ FILE: apps/web/public/schema.json ================================================ { "type": "object", "properties": { "name": { "type": "string", "maxLength": 1024, "minLength": 1, "description": "Display name for the project. Max length 1024 characters." }, "slug": { "type": "string", "minLength": 1, "description": "Unique project slug. Must be ascii-only, lower-case, and kebab-case with no spaces between 1 and 256 characters. If not provided, it will be derived by slugifying `name`." }, "version": { "type": "string", "minLength": 1, "description": "Optional semantic version of the project as a semver string. Ex: 1.0.0, 0.0.1, 5.0.1, etc." }, "description": { "type": "string", "description": "A short description of the project." }, "readme": { "type": "string", "description": "Optional markdown readme documenting the project (supports GitHub-flavored markdown)." }, "icon": { "type": "string", "description": "Optional logo image to use for the project. Logos should have a square aspect ratio." }, "sourceUrl": { "type": "string", "format": "uri", "description": "Optional URL to the source code of the project (eg, GitHub repo)." }, "homepageUrl": { "type": "string", "format": "uri", "description": "Optional URL to the product's homepage." }, "origin": { "anyOf": [ { "type": "object", "properties": { "location": { "type": "string", "const": "external", "default": "external" }, "url": { "type": "string", "format": "uri", "description": "Required base URL of the externally hosted origin API server. Must be a valid `https` URL.\n\nNOTE: Agentic currently only supports `external` API servers. If you'd like to host your API or MCP server on Agentic's infrastructure, please reach out to support@agentic.so." }, "type": { "type": "string", "const": "openapi" }, "spec": { "type": "string", "description": "Local file path, URL, or JSON stringified OpenAPI spec describing the origin API server." } }, "required": [ "url", "type", "spec" ], "additionalProperties": false }, { "type": "object", "properties": { "location": { "$ref": "#/properties/origin/anyOf/0/properties/location" }, "url": { "$ref": "#/properties/origin/anyOf/0/properties/url" }, "type": { "type": "string", "const": "mcp" }, "headers": { "type": "object", "additionalProperties": { "type": "string" } } }, "required": [ "url", "type" ], "additionalProperties": false }, { "type": "object", "properties": { "location": { "$ref": "#/properties/origin/anyOf/0/properties/location" }, "url": { "$ref": "#/properties/origin/anyOf/0/properties/url" }, "type": { "type": "string", "const": "raw" } }, "required": [ "url", "type" ], "additionalProperties": false } ], "description": "Origin adapter is used to configure the origin API server downstream from Agentic's API gateway. It specifies whether the origin API server denoted by `url` is hosted externally or deployed internally to Agentic's infrastructure. It also specifies the format for how origin tools are defined: either an OpenAPI spec or an MCP server.\n\nNOTE: Agentic currently only supports `external` API servers. If you'd like to host your API or MCP server on Agentic's infrastructure, please reach out to support@agentic.so." }, "pricingPlans": { "type": "array", "items": { "type": "object", "properties": { "name": { "type": "string", "minLength": 1, "description": "Display name for the pricing plan (eg, \"Free\", \"Starter Monthly\", \"Pro Annual\", etc)" }, "slug": { "type": "string", "minLength": 1, "description": "PricingPlan slug (eg, \"free\", \"starter-monthly\", \"pro-annual\", etc). Should be lower-cased and kebab-cased. Should be stable across deployments." }, "interval": { "type": "string", "enum": [ "day", "week", "month", "year" ], "description": "The frequency at which a subscription is billed." }, "description": { "type": "string" }, "features": { "type": "array", "items": { "type": "string" } }, "trialPeriodDays": { "type": "number", "minimum": 0 }, "rateLimit": { "anyOf": [ { "type": "object", "properties": { "enabled": { "type": "boolean", "const": false } }, "required": [ "enabled" ], "additionalProperties": false }, { "type": "object", "properties": { "interval": { "anyOf": [ { "type": "number", "exclusiveMinimum": 0 }, { "type": "string", "minLength": 1 } ], "description": "The interval at which the rate limit is applied. Either a positive integer expressed in seconds or a valid positive [ms](https://github.com/vercel/ms) string (eg, \"10s\", \"1m\", \"8h\", \"2d\", \"1w\", \"1y\", etc)." }, "limit": { "type": "number", "minimum": 0, "description": "Maximum number of operations per interval (unitless)." }, "mode": { "type": "string", "enum": [ "strict", "approximate" ], "default": "approximate", "description": "How to enforce the rate limit: \"strict\" (more precise but slower) or \"approximate\" (the default; faster and asynchronous but less precise)." }, "enabled": { "type": "boolean", "default": true } }, "required": [ "interval", "limit" ], "additionalProperties": false } ] }, "lineItems": { "type": "array", "items": { "anyOf": [ { "type": "object", "properties": { "slug": { "type": "string" }, "label": { "type": "string" }, "usageType": { "type": "string", "const": "licensed" }, "amount": { "type": "number", "minimum": 0 } }, "required": [ "slug", "usageType", "amount" ], "additionalProperties": false }, { "type": "object", "properties": { "slug": { "$ref": "#/properties/pricingPlans/items/properties/lineItems/items/anyOf/0/properties/slug" }, "label": { "$ref": "#/properties/pricingPlans/items/properties/lineItems/items/anyOf/0/properties/label" }, "usageType": { "type": "string", "const": "metered" }, "unitLabel": { "type": "string" }, "billingScheme": { "type": "string", "enum": [ "per_unit", "tiered" ] }, "unitAmount": { "type": "number", "minimum": 0 }, "tiersMode": { "type": "string", "enum": [ "graduated", "volume" ] }, "tiers": { "type": "array", "items": { "type": "object", "properties": { "unitAmount": { "type": "number" }, "flatAmount": { "type": "number" }, "upTo": { "anyOf": [ { "type": "number" }, { "type": "string", "const": "inf" } ] } }, "required": [ "upTo" ], "additionalProperties": false }, "minItems": 1 }, "defaultAggregation": { "type": "object", "properties": { "formula": { "type": "string", "enum": [ "sum", "count" ], "default": "sum" } }, "additionalProperties": false }, "transformQuantity": { "type": "object", "properties": { "divideBy": { "type": "number", "exclusiveMinimum": 0 }, "round": { "type": "string", "enum": [ "down", "up" ] } }, "required": [ "divideBy", "round" ], "additionalProperties": false } }, "required": [ "slug", "usageType", "billingScheme" ], "additionalProperties": false } ], "description": "PricingPlanLineItems represent a single line-item in a Stripe Subscription. They map to a Stripe billing `Price` and possibly a corresponding Stripe `Meter` for usage-based line-items." }, "minItems": 1, "maxItems": 20 } }, "required": [ "name", "slug", "lineItems" ], "additionalProperties": false, "description": "Represents the config for a Stripe subscription with one or more PricingPlanLineItems." }, "minItems": 1, "description": "List of PricingPlans configuring which Stripe subscriptions should be available for the project. Defaults to a single free plan which is useful for developing and testing your project.", "default": [ { "name": "Free", "slug": "free", "lineItems": [ { "slug": "base", "usageType": "licensed", "amount": 0 } ], "rateLimit": { "enabled": true, "interval": 60, "limit": 1000, "mode": "approximate" } } ] }, "pricingIntervals": { "type": "array", "items": { "$ref": "#/properties/pricingPlans/items/properties/interval" }, "minItems": 1, "description": "Optional list of billing intervals to enable in the pricingPlans.\n\nDefaults to a single monthly interval `['month']`.\n\nTo add support for annual pricing plans, for example, you can use: `['month', 'year']`.", "default": [ "month" ] }, "defaultRateLimit": { "$ref": "#/properties/pricingPlans/items/properties/rateLimit", "default": { "enabled": true, "interval": 60, "limit": 1000, "mode": "approximate" } }, "toolConfigs": { "type": "array", "items": { "type": "object", "properties": { "name": { "type": "string", "minLength": 1, "pattern": "^[a-zA-Z_][a-zA-Z0-9_-]{0,63}$", "description": "Agentic tool name" }, "enabled": { "type": "boolean" }, "pure": { "type": "boolean" }, "cacheControl": { "type": "string" }, "reportUsage": { "type": "boolean" }, "rateLimit": { "$ref": "#/properties/pricingPlans/items/properties/rateLimit" }, "inputSchemaAdditionalProperties": { "type": "boolean" }, "outputSchemaAdditionalProperties": { "type": "boolean" }, "pricingPlanOverridesMap": { "type": "object", "additionalProperties": { "type": "object", "properties": { "enabled": { "type": "boolean" }, "reportUsage": { "type": "boolean" }, "rateLimit": { "$ref": "#/properties/pricingPlans/items/properties/rateLimit" } }, "additionalProperties": false }, "propertyNames": { "minLength": 1 }, "description": "Allows you to override this tool's behavior or disable it entirely for different pricing plans. This is a map of PricingPlan slug to PricingPlanToolOverrides for that plan." }, "examples": { "type": "array", "items": { "type": "object", "properties": { "name": { "type": "string", "description": "The display name of the example. If not given, defaults to `Example 1`, `Example 2`, etc." }, "prompt": { "type": "string", "description": "The input prompt for agents to use when running this example." }, "systemPrompt": { "type": "string", "description": "An optional system prompt for agents to use when running this example. Defaults to `You are a helpful assistant. Be as concise as possible.`" }, "args": { "type": "object", "additionalProperties": {}, "description": "The arguments to pass to the tool for this example." }, "featured": { "type": "boolean", "description": "Whether this example should be featured in the docs for the project." }, "description": { "type": "string", "description": "A description of the example." } }, "required": [ "prompt", "args" ], "additionalProperties": false }, "description": "Examples of how to use this tool. Used to generate example usage in the tool's docs." } }, "required": [ "name" ], "additionalProperties": false }, "default": [] } }, "required": [ "name", "origin" ], "additionalProperties": false, "$schema": "https://json-schema.org/draft-07/schema", "title": "Agentic Project Config Schema", "description": "JSON Schema used by `agentic.config.{ts,js,json}` files to configure Agentic projects." } ================================================ FILE: apps/web/readme.md ================================================ ## TODO - better auth error handling - display validation errors in auth forms - race condition on login; sometimes doesn't actually login ================================================ FILE: apps/web/src/app/about/page.tsx ================================================ import Link from 'next/link' import { DotsSection } from '@/components/dots-section' import { Markdown } from '@/components/markdown' import { PageContainer } from '@/components/page-container' import { SupplySideCTA } from '@/components/supply-side-cta' import { githubUrl, twitterUrl } from '@/lib/config' export default function AboutPage() { return (

About

Setting the stage

Building reliable agents is hard.

But building them without the right tools is even harder.

MCP is really promising, but it's still early and the ecosystem's a bit of a mess.

Add to that the fact that most MCPs are just thin wrappers around REST APIs – which works, but is far from ideal in terms of context efficiency and instruction following.

The best agents require their tools to be optimized for LLM usage with a fundamentally different UX than REST APIs.

There's a lot that will change in AI over the next decade, but one thing I believe strongly is that no matter how much the underlying AI systems change,{' '} providing access to high quality tools that are specifically designed and optimized for agents will become increasingly important .

We call this Agentic UX, and it's at the heart of Agentic's mission.

Mission

Agentic's mission is to provide the world's best library of tools for AI agents.

{/*

What is Agentic UX?

Agentic User Experience measures how optimized a resource is for consumption by LLM-based apps and more autonomous AI agents.

`llms.txt` is a great example of a readonly format optimized for Agentic UX.

Anthropic's Model Context Protocol (MCP) and Google's Agent to Agent Protocol (A2A) are both examples of protocols purpose-built for Agentic UX. There are dozens of other aspirational protocols with similar aims. [xkcd standards]

*/}

Team

Agentic was founded in 2023 by{' '} Travis Fischer {' '} (hey hey 👋) . We're backed by{' '} HF0 ,{' '} Backend Capital , and Transpose Capital .

I'm currently running Agentic as a solo founder while traveling around the world, but i'm actively looking to hire a few remote engineers and would consider bringing on a co-founder if they're a really strong fit.

If you're an expert TypeScript dev who vibes with our mission and loves open source – and if you have an interest in AI engineering, AI agents, API gateways, OpenAPI, MCP, AI codegen, etc, feel free to{' '} DM me on twitter , and please include a few links to your GitHub + related projects.

(this page was written with love and an intentional lack of LLM assistance on a very long and sleepy international flight 💕)

Tech stack

  • TypeScript
  • Node.js
  • Postgres
  • Drizzle ORM
  • Hono
  • Next.js
  • Stripe
  • Cloudflare Workers
  • Vercel
  • Sentry
  • Resend
  • Cursor

Check out the source on GitHub for more details .

{/* CTA section */}

Don't miss out on the AI wave

) } ================================================ FILE: apps/web/src/app/app/app-dashboard.tsx ================================================ import { AppConsumersList } from '@/components/app-consumers-list' import { AppProjectsList } from '@/components/app-projects-list' import { PageContainer } from '@/components/page-container' export function AppDashboard() { return (

Dashboard

) } ================================================ FILE: apps/web/src/app/app/consumers/[consumerId]/app-consumer-index.tsx ================================================ 'use client' import { Loader2Icon } from 'lucide-react' import { useSearchParams } from 'next/navigation' import { useCallback, useEffect, useRef, useState } from 'react' import { toast } from 'sonner' import { useAuthenticatedAgentic } from '@/components/agentic-provider' import { useConfettiFireworks } from '@/components/confetti' import { LoadingIndicator } from '@/components/loading-indicator' import { PageContainer } from '@/components/page-container' import { Button } from '@/components/ui/button' import { toastError } from '@/lib/notifications' import { useQuery } from '@/lib/query-client' export function AppConsumerIndex({ consumerId }: { consumerId: string }) { const ctx = useAuthenticatedAgentic() const searchParams = useSearchParams() const checkout = searchParams.get('checkout') const plan = searchParams.get('plan') const { fireConfetti } = useConfettiFireworks() const [isLoadingStripeBillingPortal, setIsLoadingStripeBillingPortal] = useState(false) const { data: consumer, isLoading, isError } = useQuery({ queryKey: ['consumer', consumerId], queryFn: () => ctx!.api.getConsumer({ consumerId, populate: ['project'] }), enabled: !!ctx }) const firstLoadConsumer = useRef(true) useEffect(() => { if (!ctx || !consumer || !firstLoadConsumer.current) return if (checkout === 'canceled') { firstLoadConsumer.current = false toast('Subscription canceled') } else if (checkout === 'success') { if (plan) { firstLoadConsumer.current = false toast( `Congrats! You are now subscribed to the "${plan}" plan for project "${consumer.project.name}"`, { duration: 10_000 } ) // Return the confetti cleanup handler, so if this component is // unmounted, the confetti will stop as well. return fireConfetti() } else { firstLoadConsumer.current = false toast( `Your subscription has been cancelled for project "${consumer.project.name}"`, { duration: 10_000 } ) } } }, [checkout, ctx, plan, consumer, fireConfetti]) const onManageSubscription = useCallback(async () => { if (!ctx || !consumer) { void toastError('Failed to create billing portal session') return } let url: string | undefined try { setIsLoadingStripeBillingPortal(true) const res = await ctx!.api.createConsumerBillingPortalSession({ consumerId: consumer.id }) url = res.url } catch (err) { void toastError(err, { label: 'Error creating billing portal session' }) } finally { setIsLoadingStripeBillingPortal(false) } if (url) { globalThis.open(url, '_blank') } }, [ctx, consumer]) return (
{!ctx || isLoading ? ( ) : isError ? (

Error fetching customer subscription "{consumerId}"

) : !consumer ? (

Customer subscription "{consumerId}" not found

) : ( <>

Subscription to {consumer.project.name}

                {JSON.stringify(consumer, null, 2)}
              
)}
) } ================================================ FILE: apps/web/src/app/app/consumers/[consumerId]/page.tsx ================================================ import { AppConsumerIndex } from './app-consumer-index' export default async function AppConsumerIndexPage({ params }: { params: Promise<{ consumerId: string }> }) { const { consumerId } = await params return } ================================================ FILE: apps/web/src/app/app/consumers/app-consumers-index.tsx ================================================ 'use client' import { Loader2Icon } from 'lucide-react' import { useCallback, useState } from 'react' import { useAuthenticatedAgentic } from '@/components/agentic-provider' import { AppConsumersList } from '@/components/app-consumers-list' import { PageContainer } from '@/components/page-container' import { Button } from '@/components/ui/button' import { toastError } from '@/lib/notifications' export function AppConsumersIndex() { const ctx = useAuthenticatedAgentic() const [isLoadingStripeBillingPortal, setIsLoadingStripeBillingPortal] = useState(false) const onManageSubscriptions = useCallback(async () => { let url: string | undefined try { setIsLoadingStripeBillingPortal(true) const res = await ctx!.api.createBillingPortalSession() url = res.url } catch (err) { void toastError(err, { label: 'Error creating billing portal session' }) } finally { setIsLoadingStripeBillingPortal(false) } if (url) { globalThis.open(url, '_blank') } }, [ctx]) return (

Subscriptions

) } ================================================ FILE: apps/web/src/app/app/consumers/page.tsx ================================================ import { AppConsumersIndex } from './app-consumers-index' export default function AppConsumersIndexPage() { return } ================================================ FILE: apps/web/src/app/app/page.tsx ================================================ import { AppDashboard } from './app-dashboard' export default function AppIndexPage() { return } ================================================ FILE: apps/web/src/app/app/projects/[namespace]/[project-slug]/app-project-index.tsx ================================================ 'use client' import { useAuthenticatedAgentic } from '@/components/agentic-provider' import { LoadingIndicator } from '@/components/loading-indicator' import { PageContainer } from '@/components/page-container' import { useQuery } from '@/lib/query-client' export function AppProjectIndex({ projectIdentifier }: { projectIdentifier: string }) { const ctx = useAuthenticatedAgentic() const { data: project, isLoading, isError } = useQuery({ queryKey: ['project', projectIdentifier], queryFn: () => ctx!.api.getProjectByIdentifier({ projectIdentifier, populate: ['lastPublishedDeployment'] }), enabled: !!ctx }) // TODO: show deployments return (
{!ctx || isLoading ? ( ) : isError ? (

Error fetching project

) : !project ? (

Project "{projectIdentifier}" not found

) : ( <>

{project.name}

{JSON.stringify(project, null, 2)}
)}
) } ================================================ FILE: apps/web/src/app/app/projects/[namespace]/[project-slug]/page.tsx ================================================ import { parseProjectIdentifier } from '@agentic/platform-validators' import { notFound } from 'next/navigation' import { toastError } from '@/lib/notifications' import { AppProjectIndex } from './app-project-index' export default async function AppProjectIndexPage({ params }: { params: Promise<{ namespace: string 'project-slug': string }> }) { const { namespace: rawNamespace, 'project-slug': rawProjectSlug } = await params try { const namespace = decodeURIComponent(rawNamespace) const projectSlug = decodeURIComponent(rawProjectSlug) const { projectIdentifier } = parseProjectIdentifier( `${namespace}/${projectSlug}`, { strict: true } ) return } catch (err: any) { void toastError(err, { label: 'Invalid project identifier' }) return notFound() } } ================================================ FILE: apps/web/src/app/app/projects/app-projects-index.tsx ================================================ import { AppProjectsList } from '@/components/app-projects-list' import { PageContainer } from '@/components/page-container' export function AppProjectsIndex() { return (
) } ================================================ FILE: apps/web/src/app/app/projects/page.tsx ================================================ import { AppProjectsIndex } from './app-projects-index' export default function AppProjectsIndexPage() { return } ================================================ FILE: apps/web/src/app/app/temp-testing ================================================ await new Promise((resolve) => setTimeout(resolve, 2000)) console.log(projects) const p = [ { id: 'proj_pk4ui2lpcepx1aaf21zlf0lj' + pageParam, createdAt: '2025-06-16 01:50:23.948105', updatedAt: '2025-06-16 01:50:23.948105', identifier: '@dev/test-everything-openapi', name: 'test-everything-openapi', userId: 'user_x7awoo6vxk7acinkjx1fc6kf', lastDeploymentId: 'depl_ub815xwoj8bzj1gqdlfzim91', applicationFeePercent: 20, defaultPricingInterval: 'month', pricingCurrency: 'usd' }, { id: 'proj_j5lvlamp2fax4n7kx09eypjx' + pageParam, createdAt: '2025-06-16 01:50:20.288698', updatedAt: '2025-06-16 01:50:20.288698', identifier: '@dev/test-basic-mcp', name: 'test-basic-mcp', userId: 'user_x7awoo6vxk7acinkjx1fc6kf', lastDeploymentId: 'depl_krkn9nedwes1s662kky7a991', applicationFeePercent: 20, defaultPricingInterval: 'month', pricingCurrency: 'usd' }, { id: 'proj_xrt1zzegoa3sun3kynh7itss' + pageParam, createdAt: '2025-06-16 01:50:17.138037', updatedAt: '2025-06-16 01:50:17.138037', identifier: '@dev/test-basic-openapi', name: 'test-basic-openapi', userId: 'user_x7awoo6vxk7acinkjx1fc6kf', lastDeploymentId: 'depl_frbnya7wukdto64y93osfp8a', applicationFeePercent: 20, defaultPricingInterval: 'month', pricingCurrency: 'usd' }, { id: 'proj_pk4ui2lpcepx1aaf21zlf0lj' + 'foo' + pageParam, createdAt: '2025-06-16 01:50:23.948105', updatedAt: '2025-06-16 01:50:23.948105', identifier: '@dev/test-everything-openapi', name: 'test-everything-openapi', userId: 'user_x7awoo6vxk7acinkjx1fc6kf', lastDeploymentId: 'depl_ub815xwoj8bzj1gqdlfzim91', applicationFeePercent: 20, defaultPricingInterval: 'month', pricingCurrency: 'usd' }, { id: 'proj_j5lvlamp2fax4n7kx09eypjx' + 'foo' + pageParam, createdAt: '2025-06-16 01:50:20.288698', updatedAt: '2025-06-16 01:50:20.288698', identifier: '@dev/test-basic-mcp', name: 'test-basic-mcp', userId: 'user_x7awoo6vxk7acinkjx1fc6kf', lastDeploymentId: 'depl_krkn9nedwes1s662kky7a991', applicationFeePercent: 20, defaultPricingInterval: 'month', pricingCurrency: 'usd' }, { id: 'proj_xrt1zzegoa3sun3kynh7itss' + 'foo' + pageParam, createdAt: '2025-06-16 01:50:17.138037', updatedAt: '2025-06-16 01:50:17.138037', identifier: '@dev/test-basic-openapi', name: 'test-basic-openapi', userId: 'user_x7awoo6vxk7acinkjx1fc6kf', lastDeploymentId: 'depl_frbnya7wukdto64y93osfp8a', applicationFeePercent: 20, defaultPricingInterval: 'month', pricingCurrency: 'usd' }, { id: 'proj_pk4ui2lpcepx1aaf21zlf0lj' + 'bar' + pageParam, createdAt: '2025-06-16 01:50:23.948105', updatedAt: '2025-06-16 01:50:23.948105', identifier: '@dev/test-everything-openapi', name: 'test-everything-openapi', userId: 'user_x7awoo6vxk7acinkjx1fc6kf', lastDeploymentId: 'depl_ub815xwoj8bzj1gqdlfzim91', applicationFeePercent: 20, defaultPricingInterval: 'month', pricingCurrency: 'usd' }, { id: 'proj_j5lvlamp2fax4n7kx09eypjx' + 'bar' + pageParam, createdAt: '2025-06-16 01:50:20.288698', updatedAt: '2025-06-16 01:50:20.288698', identifier: '@dev/test-basic-mcp', name: 'test-basic-mcp', userId: 'user_x7awoo6vxk7acinkjx1fc6kf', lastDeploymentId: 'depl_krkn9nedwes1s662kky7a991', applicationFeePercent: 20, defaultPricingInterval: 'month', pricingCurrency: 'usd' }, { id: 'proj_xrt1zzegoa3sun3kynh7itss' + 'bar' + pageParam, createdAt: '2025-06-16 01:50:17.138037', updatedAt: '2025-06-16 01:50:17.138037', identifier: '@dev/test-basic-openapi', name: 'test-basic-openapi', userId: 'user_x7awoo6vxk7acinkjx1fc6kf', lastDeploymentId: 'depl_frbnya7wukdto64y93osfp8a', applicationFeePercent: 20, defaultPricingInterval: 'month', pricingCurrency: 'usd' }, { id: 'proj_pk4ui2lpcepx1aaf21zlf0lj' + 'baz' + pageParam, createdAt: '2025-06-16 01:50:23.948105', updatedAt: '2025-06-16 01:50:23.948105', identifier: '@dev/test-everything-openapi', name: 'test-everything-openapi', userId: 'user_x7awoo6vxk7acinkjx1fc6kf', lastDeploymentId: 'depl_ub815xwoj8bzj1gqdlfzim91', applicationFeePercent: 20, defaultPricingInterval: 'month', pricingCurrency: 'usd' } ] if (pageParam < 200) { projects = p as any } ================================================ FILE: apps/web/src/app/auth/[provider]/success/oauth-success-callback.tsx ================================================ 'use client' import { sanitizeSearchParams } from '@agentic/platform-core' import { useRouter, useSearchParams } from 'next/navigation' import { useEffect } from 'react' import { useNextUrl, useUnauthenticatedAgentic } from '@/components/agentic-provider' import { LoadingIndicator } from '@/components/loading-indicator' import { toastError } from '@/lib/notifications' export function OAuthSuccessCallback({ provider: // TODO: make generic using this provider instead of hard-coding github _provider }: { provider: string }) { const searchParams = useSearchParams() const code = searchParams.get('code') const ctx = useUnauthenticatedAgentic() const nextUrl = useNextUrl() const router = useRouter() useEffect(() => { ;(async function () { if (!ctx) { return } if (!code) { // TODO throw new Error('Missing code or challenge') } // TODO: make generic using `provider` try { await ctx.api.exchangeOAuthCodeWithGitHub({ code }) } catch (err) { await toastError(err, { label: 'Auth error' }) return router.replace( `/login?${sanitizeSearchParams({ next: nextUrl }).toString()}` ) } return router.replace(nextUrl || '/app') })() }, [code, ctx, nextUrl, router]) return } ================================================ FILE: apps/web/src/app/auth/[provider]/success/page.tsx ================================================ import { assert } from '@agentic/platform-core' import { Suspense } from 'react' import { OAuthSuccessCallback } from './oauth-success-callback' export default async function Page({ params }: { params: Promise<{ provider: string }> }) { const { provider } = await params assert(provider, 'Missing provider') return ( ) } ================================================ FILE: apps/web/src/app/contact/page.tsx ================================================ import Link from 'next/link' import { DotsSection } from '@/components/dots-section' import { GitHubStarCounter } from '@/components/github-star-counter' import { HeroButton } from '@/components/hero-button' import { PageContainer } from '@/components/page-container' import { Button } from '@/components/ui/button' import { calendarBookingUrl, emailUrl, twitterUrl } from '@/lib/config' export default function AboutPage() { return (

Contact

Agentic is currently a solo effort by{' '} Travis Fischer . 👋

As with MCP itself, Agentic is an active work in progress, so please reach out if you have any questions, feedback, or feature requests.

{/* CTA section */}
DM me on Twitter / X
) } ================================================ FILE: apps/web/src/app/globals.css ================================================ @import 'tailwindcss'; @plugin '@tailwindcss/typography'; @custom-variant dark (&:is(.dark *)); @theme { --font-heading: var(--font-geist); --font-body: var(--font-geist); } :root { --radius: 0.625rem; --background: oklch(1 0 0); --foreground: oklch(0.145 0 0); --card: oklch(1 0 0); --card-foreground: oklch(0.145 0 0); --popover: oklch(1 0 0); --popover-foreground: oklch(0.145 0 0); --primary: oklch(0.205 0 0); --primary-foreground: oklch(0.985 0 0); --secondary: oklch(0.97 0 0); --secondary-foreground: oklch(0.205 0 0); --muted: oklch(0.97 0 0); --muted-foreground: oklch(0.556 0 0); --accent: oklch(0.97 0 0); --accent-foreground: oklch(0.205 0 0); --destructive: oklch(0.577 0.245 27.325); --border: oklch(0.922 0 0); --input: oklch(0.922 0 0); --ring: oklch(0.708 0 0); --chart-1: oklch(0.646 0.222 41.116); --chart-2: oklch(0.6 0.118 184.704); --chart-3: oklch(0.398 0.07 227.392); --chart-4: oklch(0.828 0.189 84.429); --chart-5: oklch(0.769 0.188 70.08); --sidebar: oklch(0.985 0 0); --sidebar-foreground: oklch(0.145 0 0); --sidebar-primary: oklch(0.205 0 0); --sidebar-primary-foreground: oklch(0.985 0 0); --sidebar-accent: oklch(0.97 0 0); --sidebar-accent-foreground: oklch(0.205 0 0); --sidebar-border: oklch(0.922 0 0); --sidebar-ring: oklch(0.708 0 0); } .dark { --background: oklch(0.145 0 0); --foreground: oklch(0.985 0 0); --card: oklch(0.205 0 0); --card-foreground: oklch(0.985 0 0); --popover: oklch(0.205 0 0); --popover-foreground: oklch(0.985 0 0); --primary: oklch(0.922 0 0); --primary-foreground: oklch(0.205 0 0); --secondary: oklch(0.269 0 0); --secondary-foreground: oklch(0.985 0 0); --muted: oklch(0.269 0 0); --muted-foreground: oklch(0.708 0 0); --accent: oklch(0.269 0 0); --accent-foreground: oklch(0.985 0 0); --destructive: oklch(0.704 0.191 22.216); --border: oklch(1 0 0 / 10%); --input: oklch(1 0 0 / 15%); --ring: oklch(0.556 0 0); --chart-1: oklch(0.488 0.243 264.376); --chart-2: oklch(0.696 0.17 162.48); --chart-3: oklch(0.769 0.188 70.08); --chart-4: oklch(0.627 0.265 303.9); --chart-5: oklch(0.645 0.246 16.439); --sidebar: oklch(0.205 0 0); --sidebar-foreground: oklch(0.985 0 0); --sidebar-primary: oklch(0.488 0.243 264.376); --sidebar-primary-foreground: oklch(0.985 0 0); --sidebar-accent: oklch(0.269 0 0); --sidebar-accent-foreground: oklch(0.985 0 0); --sidebar-border: oklch(1 0 0 / 10%); --sidebar-ring: oklch(0.556 0 0); } @theme inline { --radius-sm: calc(var(--radius) - 4px); --radius-md: calc(var(--radius) - 2px); --radius-lg: var(--radius); --radius-xl: calc(var(--radius) + 4px); --color-background: var(--background); --color-foreground: var(--foreground); --color-card: var(--card); --color-card-foreground: var(--card-foreground); --color-popover: var(--popover); --color-popover-foreground: var(--popover-foreground); --color-primary: var(--primary); --color-primary-foreground: var(--primary-foreground); --color-secondary: var(--secondary); --color-secondary-foreground: var(--secondary-foreground); --color-muted: var(--muted); --color-muted-foreground: var(--muted-foreground); --color-accent: var(--accent); --color-accent-foreground: var(--accent-foreground); --color-destructive: var(--destructive); --color-border: var(--border); --color-input: var(--input); --color-ring: var(--ring); --color-chart-1: var(--chart-1); --color-chart-2: var(--chart-2); --color-chart-3: var(--chart-3); --color-chart-4: var(--chart-4); --color-chart-5: var(--chart-5); --color-sidebar: var(--sidebar); --color-sidebar-foreground: var(--sidebar-foreground); --color-sidebar-primary: var(--sidebar-primary); --color-sidebar-primary-foreground: var(--sidebar-primary-foreground); --color-sidebar-accent: var(--sidebar-accent); --color-sidebar-accent-foreground: var(--sidebar-accent-foreground); --color-sidebar-border: var(--sidebar-border); --color-sidebar-ring: var(--sidebar-ring); } @layer base { * { @apply border-border outline-ring/50; } body { @apply bg-background text-foreground; } } body { font-family: var(--font-body), Arial, Helvetica, sans-serif; } * { box-sizing: border-box; } html, body { max-width: 100vw; height: 100%; } a { text-decoration: none; } a:hover { text-decoration: none; } a.link, a .link { position: relative; transition: unset; opacity: 1; padding-bottom: 0.1rem; border-bottom-width: 0.1rem; border-bottom-color: transparent; background: transparent; background-origin: border-box; background-repeat: no-repeat; background-position: 50% 100%; background-size: 0 0.1rem; } a.link:focus, a.link:hover, a:focus .link, a:hover .link { border-bottom-color: transparent; background-image: linear-gradient(90.68deg, #b439df 0.26%, #e5337e 102.37%); background-repeat: no-repeat; background-position: 0 100%; background-size: 100% 0.1rem; transition-property: background-position, background-size; transition-duration: 300ms; } main section { width: 100%; display: flex; flex-direction: column; align-items: center; } main section:last-of-type { margin-bottom: 0; } ================================================ FILE: apps/web/src/app/layout.tsx ================================================ import './globals.css' import type { Metadata } from 'next' import { Geist } from 'next/font/google' import { Toaster } from 'sonner' import { Bootstrap } from '@/components/bootstrap' import { Footer } from '@/components/footer' import { Header } from '@/components/header' import * as config from '@/lib/config' import Providers from './providers' const geist = Geist({ variable: '--font-geist', subsets: ['latin'] }) export const metadata: Metadata = { title: config.title, description: config.description, authors: [{ name: config.author, url: config.twitterUrl }], metadataBase: new URL(config.prodUrl), keywords: config.keywords, openGraph: { title: config.title, description: config.description, siteName: config.title, locale: 'en_US', type: 'website', url: config.prodUrl }, twitter: { card: 'summary_large_image', creator: `@${config.authorTwitterUsername}`, title: config.title, description: config.description } } export default function RootLayout({ children }: Readonly<{ children: React.ReactNode }>) { return (
{children}
) } ================================================ FILE: apps/web/src/app/login/login-form.tsx ================================================ 'use client' import { sanitizeSearchParams } from '@agentic/platform-core' import { isValidEmail, isValidPassword } from '@agentic/platform-validators' import { useForm } from '@tanstack/react-form' import { Loader2Icon } from 'lucide-react' import { useRouter } from 'next/navigation' import { useCallback, useState } from 'react' import { z } from 'zod' import { useNextUrl, useUnauthenticatedAgentic } from '@/components/agentic-provider' import { Button } from '@/components/ui/button' import { Input } from '@/components/ui/input' import { Label } from '@/components/ui/label' import { GitHubIcon } from '@/icons/github' import { toastError } from '@/lib/notifications' import { cn } from '@/lib/utils' export function LoginForm() { const ctx = useUnauthenticatedAgentic() const nextUrl = useNextUrl() const router = useRouter() const [isGitHubLoading, setIsGitHubLoading] = useState(false) const onAuthWithGitHub = useCallback(async () => { setIsGitHubLoading(true) try { const redirectUri = `${globalThis.location.origin}/auth/github/success?${sanitizeSearchParams({ next: nextUrl }).toString()}` const url = await ctx!.api.initAuthFlowWithGitHub({ redirectUri }) void router.push(url) } catch (err: any) { setIsGitHubLoading(false) void toastError(err, { label: 'GitHub auth error' }) } }, [ctx, nextUrl, router]) const form = useForm({ defaultValues: { email: '', password: '' }, validators: { onChange: z.object({ email: z .string() .email() .refine((email) => isValidEmail(email)), password: z.string().refine((password) => isValidPassword(password)) }) }, onSubmit: async ({ value }) => { try { const res = await ctx!.api.signInWithPassword({ email: value.email, password: value.password }) console.log('login success', res) } catch (err: any) { void toastError(err, { label: 'Login error' }) return } return router.push(nextUrl || '/app') } }) return (
{ e.preventDefault() void form.handleSubmit() }} >

Login to your account

(
field.handleChange(e.target.value)} />
)} /> (
{/* Forgot your password? */}
field.handleChange(e.target.value)} />
)} /> [ state.canSubmit, state.isSubmitting, state.isTouched ]} children={([canSubmit, isSubmitting, isTouched]) => ( )} />
Or continue with
{ e.preventDefault() void onAuthWithGitHub() }} >
Don't have an account?{' '} Sign up
) } ================================================ FILE: apps/web/src/app/login/page.tsx ================================================ import { Suspense } from 'react' import { PageContainer } from '@/components/page-container' import { LoginForm } from './login-form' export default function Page() { return ( ) } ================================================ FILE: apps/web/src/app/logout/page.tsx ================================================ 'use client' import { useEffect } from 'react' import { useAuthenticatedAgentic } from '@/components/agentic-provider' export default function LogoutPage() { const ctx = useAuthenticatedAgentic() useEffect(() => { ;(async () => { if (ctx) { ctx.logout() } })() }, [ctx]) return null } ================================================ FILE: apps/web/src/app/marketplace/marketplace-index.tsx ================================================ 'use client' import useInfiniteScroll from 'react-infinite-scroll-hook' import { DotsSection } from '@/components/dots-section' import { LoadingIndicator } from '@/components/loading-indicator' import { PageContainer } from '@/components/page-container' import { PublicProject } from '@/components/public-project' import { SupplySideCTA } from '@/components/supply-side-cta' import { defaultAgenticApiClient } from '@/lib/default-agentic-api-client' import { useInfiniteQuery, useQuery } from '@/lib/query-client' export function MarketplaceIndex({ limit }: { limit: number }) { const { data: featuredProjects, isLoading: isFeaturedProjectsLoading, isError: isFeaturedProjectsError } = useQuery({ queryKey: ['featured-public-projects'], queryFn: () => defaultAgenticApiClient.listPublicProjects({ populate: ['lastPublishedDeployment'], limit: 3, tag: 'featured', sortBy: 'createdAt', sort: 'asc' }) }) const { data, isLoading, isError, hasNextPage, fetchNextPage, isFetchingNextPage } = useInfiniteQuery({ queryKey: ['public-projects'], queryFn: ({ pageParam = 0 }) => defaultAgenticApiClient .listPublicProjects({ populate: ['lastPublishedDeployment'], offset: pageParam, limit, notTag: 'featured' }) .then(async (projects) => { return { projects, offset: pageParam, limit, nextOffset: projects.length >= limit ? pageParam + projects.length : undefined } }), getNextPageParam: (lastGroup) => lastGroup?.nextOffset, initialPageParam: 0 }) const [sentryRef] = useInfiniteScroll({ loading: isLoading || isFetchingNextPage, hasNextPage, onLoadMore: fetchNextPage, disabled: isError, rootMargin: '0px 0px 200px 0px' }) const projects = data ? data.pages.flatMap((p) => p.projects) : [] return (

Marketplace

Featured

{isFeaturedProjectsError ? (

Error fetching featured projects

) : isFeaturedProjectsLoading ? ( ) : !featuredProjects?.length ? (

No projects found. This is likely an issue on Agentic's side. Please refresh or contact support.

) : (
{featuredProjects.map((project) => ( ))}
)}

General

{isError ? (

Error fetching projects

) : isLoading ? ( ) : !projects.length ? (

No projects found. This is likely an issue on Agentic's side. Please refresh or contact support.

) : (
{projects.map((project) => ( ))} {hasNextPage && (
{isLoading || (isFetchingNextPage && )}
)}
)}
{/* CTA section */}

Your API → Paid MCP, Instantly

Run one command to turn any MCP server or OpenAPI service into a paid MCP product. With built-in support for every major LLM SDK and MCP client.
) } ================================================ FILE: apps/web/src/app/marketplace/page.tsx ================================================ import { dehydrate, HydrationBoundary, QueryClient } from '@tanstack/react-query' import { defaultAgenticApiClient } from '@/lib/default-agentic-api-client' import { MarketplaceIndex } from './marketplace-index' export default async function MarketplaceIndexPage() { const queryClient = new QueryClient() const limit = 10 await Promise.all([ queryClient.prefetchQuery({ queryKey: ['featured-public-projects'], queryFn: () => defaultAgenticApiClient.listPublicProjects({ populate: ['lastPublishedDeployment'], limit: 3, tag: 'featured', sortBy: 'createdAt', sort: 'asc' }) }), queryClient.prefetchInfiniteQuery({ queryKey: ['public-projects'], queryFn: ({ pageParam = 0 }) => defaultAgenticApiClient .listPublicProjects({ populate: ['lastPublishedDeployment'], offset: pageParam, limit, notTag: 'featured' }) .then(async (projects) => { return { projects, offset: pageParam, limit, nextOffset: projects.length >= limit ? pageParam + projects.length : undefined } }), initialPageParam: 0 }) ]) return ( ) } ================================================ FILE: apps/web/src/app/marketplace/projects/[namespace]/[project-slug]/marketplace-nav.tsx ================================================ import { Breadcrumb, BreadcrumbItem, BreadcrumbLink, BreadcrumbList, BreadcrumbPage, BreadcrumbSeparator } from '@/components/ui/breadcrumb' export function MarketplacePublicProjectDetailNav({ projectIdentifier }: { projectIdentifier: string }) { return ( Marketplace {projectIdentifier} ) } ================================================ FILE: apps/web/src/app/marketplace/projects/[namespace]/[project-slug]/marketplace-public-project-detail.tsx ================================================ 'use client' import type { Project } from '@agentic/platform-types' import { assert, omit, sanitizeSearchParams } from '@agentic/platform-core' import ky from 'ky' import { ChevronsUpDownIcon, ExternalLinkIcon } from 'lucide-react' import Link from 'next/link' import { useRouter, useSearchParams } from 'next/navigation' import plur from 'plur' import { useCallback, useEffect, useMemo, useRef, useState } from 'react' import { useAsync } from 'react-use' import { useAgentic } from '@/components/agentic-provider' import { CodeBlock } from '@/components/code-block' import { ExampleUsage } from '@/components/example-usage' import { HeroButton } from '@/components/hero-button' import { LoadingIndicator } from '@/components/loading-indicator' import { SSRMarkdown } from '@/components/markdown/ssr-markdown' import { PageContainer } from '@/components/page-container' import { ProjectPricingPlans } from '@/components/project-pricing-plans' import { Button } from '@/components/ui/button' import { Collapsible, CollapsibleContent, CollapsibleTrigger } from '@/components/ui/collapsible' import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs' import { GitHubIcon } from '@/icons/github' import { defaultAgenticApiClient } from '@/lib/default-agentic-api-client' import { toast, toastError } from '@/lib/notifications' import { useQuery } from '@/lib/query-client' import { type MarketplacePublicProjectDetailTab, marketplacePublicProjectDetailTabsSet, MAX_TOOLS_TO_SHOW } from './utils' export function MarketplacePublicProjectDetail({ projectIdentifier }: { projectIdentifier: string }) { const ctx = useAgentic() const searchParams = useSearchParams() const checkout = searchParams.get('checkout') const plan = searchParams.get('plan') const [isLoadingStripeCheckoutForPlan, setIsLoadingStripeCheckoutForPlan] = useState(null) const router = useRouter() // Load the public project const { data: project, isLoading, isError } = useQuery({ queryKey: ['public-project', projectIdentifier], queryFn: () => defaultAgenticApiClient.getPublicProjectByIdentifier({ projectIdentifier, populate: ['lastPublishedDeployment'] }) }) // If the user is authenticated, check if they have an active subscription to // this project const { data: consumer, isLoading: isConsumerLoading // isError: isConsumerError } = useQuery({ queryKey: [ 'project', projectIdentifier, 'user', ctx?.api.authSession?.user.id ], queryFn: () => ctx!.api.getConsumerByProjectIdentifier({ projectIdentifier }), enabled: !!ctx?.isAuthenticated }) const onSubscribe = useCallback( async (pricingPlanSlug: string) => { assert(ctx, 500, 'Agentic context is required') assert(project, 500, 'Project is required') const { lastPublishedDeploymentId } = project assert( lastPublishedDeploymentId, 500, `Public project "${projectIdentifier}" expected to have a last published deployment, but none found.` ) if (!ctx.isAuthenticated) { return router.push( `/signup?${sanitizeSearchParams({ next: `/marketplace/projects/${projectIdentifier}?tab=pricing&checkout=true&plan=${pricingPlanSlug}` }).toString()}` ) } let checkoutSession: { url: string; id: string } | undefined try { setIsLoadingStripeCheckoutForPlan(pricingPlanSlug) const res = await ctx!.api.createConsumerCheckoutSession({ deploymentId: lastPublishedDeploymentId!, plan: pricingPlanSlug }) console.log('checkout', res) checkoutSession = res.checkoutSession } catch (err) { return toastError(err, { label: 'Error creating checkout session' }) } finally { setIsLoadingStripeCheckoutForPlan(null) } return router.push(checkoutSession.url) }, [ctx, projectIdentifier, project, router] ) const hasInitializedCheckoutFromSearchParams = useRef(false) useEffect(() => { if (!ctx) return if (checkout === 'canceled') { toast('Checkout canceled') } else if ( checkout === 'true' && plan && project && !isConsumerLoading && !hasInitializedCheckoutFromSearchParams.current ) { hasInitializedCheckoutFromSearchParams.current = true if (consumer?.plan !== plan) { // Start checkout flow if search params have `?checkout=true&plan={plan}` // This is to allow unauthenticated users to subscribe to a plan by first // visiting `/login` or `/signup` and then being redirected to this page // with the target checkout search params already pre-filled. // Another use case for this functionality is providing a single link to // subscribe to a specific project and pricing plan – with the checkout // details pre-filled. void onSubscribe(checkout) } } }, [ checkout, plan, ctx, project, isConsumerLoading, consumer, onSubscribe, hasInitializedCheckoutFromSearchParams ]) const deployment = useMemo(() => project?.lastPublishedDeployment, [project]) const featuredToolName = useMemo(() => { const toolConfigs = deployment?.toolConfigs?.filter( (toolConfig) => toolConfig?.enabled !== false ) return ( toolConfigs?.find((toolConfig) => toolConfig.examples?.find((example) => example.featured) )?.name ?? toolConfigs?.find((toolConfig) => toolConfig.examples?.length)?.name ?? toolConfigs?.[0]?.name ?? deployment?.tools[0]?.name ) }, [deployment]) const tab = useMemo(() => { const tab = searchParams.get('tab')?.toLowerCase() if (!tab || !marketplacePublicProjectDetailTabsSet.has(tab)) { return 'overview' } if (tab === 'readme' && !deployment?.readme?.trim()) { return 'overview' } return tab as MarketplacePublicProjectDetailTab }, [searchParams, deployment]) const { value: readme, loading: isReadmeLoading } = useAsync(async () => { if (deployment?.readme?.trim()) { return ky.get(deployment.readme).text() } return undefined }, [deployment]) const tools = useMemo(() => { if (!deployment) return [] const toolConfigsMap = new Map( deployment.toolConfigs.map((toolConfig) => [toolConfig.name, toolConfig]) ) return deployment.tools .map((tool) => { const toolConfig = toolConfigsMap.get(tool.name) if (toolConfig?.enabled === false) return null // TODO: add to tool if disabled on current pricing plan return tool }) .filter(Boolean) }, [deployment]) return (
{isLoading ? ( ) : isError ? (

Error fetching project

) : !project ? (

Project "{projectIdentifier}" not found

) : (
{ if (value === 'overview') { router.push(`/marketplace/projects/${projectIdentifier}`) } else { router.push( `/marketplace/projects/${projectIdentifier}?tab=${value}` ) } }} > Overview {deployment?.readme?.trim() && ( Readme )} Tools Pricing Debug
{tab === 'overview' && (

Overview

{deployment ? ( <>

{deployment.description || 'No description available'}

Tools

    {deployment.tools .slice(0, MAX_TOOLS_TO_SHOW) .map((tool) => (
  • {tool.name}

    {tool.description}

  • ))} {tools.length > MAX_TOOLS_TO_SHOW && (
  • )}
) : (

This project doesn't have any published deployments.

)}
)} {tab === 'readme' && ( {isReadmeLoading ? ( ) : readme ? ( ) : (

Readme not found

)}
)} {tab === 'tools' && (

Tools

{deployment && (
    {tools.map((tool) => (
  • {tool.name}

    {tool.description}

    {tool.outputSchema && ( )}
  • ))}
)}
)} {tab === 'pricing' && (

Pricing

)} {tab === 'debug' && (

Debug

{deployment && ( )} {consumer && ( )}
)}
)}
) } function ProjectHeader({ project, tab }: { project: Project tab?: MarketplacePublicProjectDetailTab }) { const ctx = useAgentic() const pricingTabHref = `/marketplace/projects/${project.identifier}?tab=pricing` return ( <> {/* */}
{project.name}

{project.name}

Subscribe to {project.identifier}
{project.identifier} {/* TODO: */}
{project.lastPublishedDeployment?.homepageUrl && ( )} {project.lastPublishedDeployment?.sourceUrl && ( )}
) } ================================================ FILE: apps/web/src/app/marketplace/projects/[namespace]/[project-slug]/page.tsx ================================================ import { parseProjectIdentifier } from '@agentic/platform-validators' import { dehydrate, HydrationBoundary, QueryClient } from '@tanstack/react-query' import { notFound } from 'next/navigation' import { defaultAgenticApiClient } from '@/lib/default-agentic-api-client' import { toastError } from '@/lib/notifications' import { MarketplacePublicProjectDetail } from './marketplace-public-project-detail' export default async function MarketplacePublicProjectDetailPage({ params }: { params: Promise<{ namespace: string 'project-slug': string }> }) { const { namespace: rawNamespace, 'project-slug': rawProjectSlug } = await params let projectIdentifier: string try { const namespace = decodeURIComponent(rawNamespace) const projectSlug = decodeURIComponent(rawProjectSlug) const parsedProjectIdentifier = parseProjectIdentifier( `${namespace}/${projectSlug}`, { strict: true } ) projectIdentifier = parsedProjectIdentifier.projectIdentifier } catch (err: any) { void toastError(err, { label: 'Invalid project identifier' }) return notFound() } const queryClient = new QueryClient() await queryClient.prefetchQuery({ queryKey: ['public-project', projectIdentifier], queryFn: () => defaultAgenticApiClient.getPublicProjectByIdentifier({ projectIdentifier, populate: ['lastPublishedDeployment'] }) }) return ( ) } ================================================ FILE: apps/web/src/app/marketplace/projects/[namespace]/[project-slug]/utils.ts ================================================ export const MAX_TOOLS_TO_SHOW = 5 export const marketplacePublicProjectDetailTabs = [ 'overview', 'readme', 'tools', 'pricing', 'debug' ] as const export const marketplacePublicProjectDetailTabsSet = new Set( marketplacePublicProjectDetailTabs ) export type MarketplacePublicProjectDetailTab = (typeof marketplacePublicProjectDetailTabs)[number] ================================================ FILE: apps/web/src/app/not-found.tsx ================================================ import { ArrowLeft } from 'lucide-react' import Image from 'next/image' import Link from 'next/link' import { Button } from '@/components/ui/button' // https://images.unsplash.com/photo-1545972154-9bb223aac798?ixid=MnwxMjA3fDB8MHxwaG90by1wYWdlfHx8fGVufDB8fHx8&ixlib=rb-1.2.1&fm=jpg&fit=crop&w=2048&q=80&exp=8&con=-15&sat=-75 import NotFoundImage from '@/public/not-found.jpg' export default function NotFound() { return ( <>
404 not found

404

Page not found

Sorry, we couldn’t find the page you’re looking for.

) } ================================================ FILE: apps/web/src/app/page.tsx ================================================ import Link from 'next/link' import { DemandSideCTA } from '@/components/demand-side-cta' import { DotsSection } from '@/components/dots-section' import { ExampleUsageSection } from '@/components/example-usage-section' import { GitHubStarCounter } from '@/components/github-star-counter' import { HeroSimulation2 } from '@/components/hero-simulation-2' import { MCPMarketplaceFeatures } from '@/components/mcp-marketplace-features' import { PageContainer } from '@/components/page-container' import { SupplySideCTA } from '@/components/supply-side-cta' import { githubUrl, twitterUrl } from '@/lib/config' export default async function TheBestDamnLandingPageEver() { return ( {/* Hero section */}

The App Store for LLM Tools

Agentic is a curated marketplace of LLM tools that work with every major LLM SDK and MCP client.
{/* Example usage section */} {/* Features section */}

Agentic tools are{' '} optimized for LLMs

{/* MCP section */}

Agentic makes MCP fun!

Agentic's mission is to provide the world's best library of tools for AI agents.

And of course, MCP is an integral part of that mission. We're working on a bunch of features designed to simplify advanced MCP use cases like bundling multiple tools, shared auth profiles, Vercel-like preview deployments, and a lot more...

{/* CTA section */}

Publish your own MCP products with Agentic

Run one command to turn any MCP server or OpenAPI service into a paid MCP product. With built-in support for every major LLM SDK and MCP client.
{/* Open source section */}

Agentic is 100% Open Source

Agentic is a fully OSS{' '} TypeScript project with a small but vibrant developer community.{' '} Check out the source on GitHub {' '} or{' '} ping me on Twitter with questions / feedback .

{/* Social proof section (TODO) */} {/*

TODO: social proof

TODO

*/} {/* Demand-side CTA section */}

Level up your AI Agents with the best tools

) } ================================================ FILE: apps/web/src/app/pricing/page.tsx ================================================ import Link from 'next/link' import { DotsSection } from '@/components/dots-section' import { GitHubStarCounter } from '@/components/github-star-counter' import { HeroButton } from '@/components/hero-button' import { PageContainer } from '@/components/page-container' import { Button } from '@/components/ui/button' import { calendarBookingUrl, emailUrl, twitterUrl } from '@/lib/config' export default function AboutPage() { return (

Pricing

Pricing for devs publishing products on Agentic is a work in progress. We're looking for early adopters to work with us to figure out the best pricing structure.

If you're interested in publishing a product on Agentic, please get in touch.

{/* CTA section */}
DM me on Twitter / X
) } ================================================ FILE: apps/web/src/app/privacy/page.tsx ================================================ import Link from 'next/link' import { Markdown } from '@/components/markdown' import { PageContainer } from '@/components/page-container' const lastUpdatedDate = 'June 30, 2025' export default function AboutPage() { return (

Privacy Policy

Last updated: {lastUpdatedDate}

1. Overview

Agentic Systems, Inc. ("Agentic",{' '} "we","us", or{' '} "our") provides a modern AI platform comprised of:

  • Marketplace – a curated directory of LLM-powered tool products that can be called via the Model Context Protocol ("MCP") or standard HTTP APIs.
  • Gateway – a fully-managed MCP gateway that allows developers to deploy, monetize, and optionally publish their own MCP or OpenAPI products.
  • Open-Source Project – the Agentic source-available codebase released under the GNU AGPL-3.0 license.

This Privacy Policy explains how we collect, use, disclose, and safeguard information in connection with the Agentic website, console, Marketplace, Gateway, and any related services (collectively, the "Service").

2. Information We Collect

We collect the following categories of information when you use the Service:

  • Account Information: name, email, billing address, and authentication credentials that you provide when you create an Agentic account.
  • Payment Information: payment method details (e.g. card type and last four digits) processed by Stripe on our behalf. Stripe's privacy practices are described in its own policy.
  • Usage Data: log files, API request metadata, IP address, browser type, referring pages, and other diagnostic information automatically collected when you interact with the Service.
  • Developer Content: API specifications, configuration, and other content that you upload to the Gateway or Marketplace.
  • Cookies & Similar Technologies: small data files placed on your device to enable site functionality, analytics, and preference storage. You can disable cookies in your browser settings, but parts of the Service may not function properly.

3. How We Use Information

We use the information we collect to:

  • Provide, maintain, and improve the Service;
  • Facilitate Marketplace and Gateway transactions, including billing through Stripe;
  • Authenticate users and secure the Service;
  • Monitor usage and detect, prevent, or address technical issues or fraudulent activity;
  • Respond to inquiries, provide customer support, and send administrative messages;
  • Send product updates, promotional communications, or other information that may be of interest to you (you may opt out at any time);
  • Carry out research, analytics, and product development;
  • Comply with legal obligations and enforce our Terms of Service.

4. Sharing & Disclosure

We may share information as follows:

  • Service Providers: with vendors who perform services on our behalf, such as hosting, analytics, and payment processing.
  • API Providers & Consumers: Marketplace product owners may receive usage metrics related to their own products; conversely, when you list a product we may display your developer profile to potential consumers.
  • Business Transfers: as part of a merger, acquisition, financing, or sale of assets.
  • Affiliates: with our corporate affiliates who are bound to honor this policy.
  • Legal Requirements: when required to comply with law or protect the rights, property, or safety of Agentic, our users, or the public.
  • With Your Consent: in any other situation where you direct us to share the information.

5. Payments via Stripe

All Marketplace purchases and Gateway subscription fees are processed by Stripe. Agentic does not store full payment-card numbers or CVC codes. Stripe acts as a separate controller of your payment information – please review the{' '} Stripe Privacy Policy {' '} for details.

6. Data Retention

We retain information for as long as necessary to fulfill the purposes described in this Policy, comply with our legal obligations, resolve disputes, and enforce our agreements. Log data is typically retained for no more than 18 months unless we are legally required to keep it longer.

7. International Transfers

We are a U.S.-based company and may process information in the United States and other countries where we or our service providers operate. We rely on appropriate safeguards, such as Standard Contractual Clauses, for the transfer of personal data from the EU/EEA, UK, and Switzerland.

8. Security

We employ technical and organizational measures designed to protect information against loss, misuse, and unauthorized access or disclosure. However, no system can be guaranteed to be 100% secure.

9. Your Rights

Depending on your jurisdiction, you may have rights to access, rectify, delete, restrict, or object to our processing of your personal information, as well as the right to data portability and to withdraw consent. To exercise these rights, please contact us as set forth below. We respond to all requests consistent with applicable law.

10. Children's Privacy

The Service is not directed to children under 13, and we do not knowingly collect personal information from children. If you believe a child has provided us with personal information, please contact us and we will take steps to delete such information.

11. Third-Party Links

The Service may contain links to third-party websites. We are not responsible for the privacy practices of those sites. We encourage you to review the privacy policies of every site you visit.

12. Changes to This Policy

We may update this Privacy Policy from time to time. We will post the revised version on this page and indicate the date of the latest revision at the top. If changes are material, we will provide additional notice (e.g., email or in-app alert) at least 7 days before they take effect.

13. Contact Us

If you have any questions or concerns about this Privacy Policy or our privacy practices, please{' '} contact us or email us at{' '} support@agentic.so.

) } ================================================ FILE: apps/web/src/app/providers.tsx ================================================ 'use client' import { QueryClient, QueryClientProvider } from '@tanstack/react-query' import { ReactQueryDevtools } from '@tanstack/react-query-devtools' import { AgenticProvider } from '@/components/agentic-provider' import { PostHogProvider } from '@/components/posthog-provider' import { ThemeProvider } from '@/components/theme-provider' import { TooltipProvider } from '@/components/ui/tooltip' import { isServer } from '@/lib/config' function createQueryClient() { return new QueryClient({ defaultOptions: { queries: { staleTime: 60 * 1000 } } }) } let browserQueryClient: QueryClient | undefined function getQueryClient(): QueryClient { if (isServer) { // Server: always make a new query client return createQueryClient() } else { // Browser: make a new query client if we don't already have one // This is very important, so we don't re-make a new client if React // suspends during the initial render. This may not be needed if we // have a suspense boundary BELOW the creation of the query client if (!browserQueryClient) { browserQueryClient = createQueryClient() } return browserQueryClient } } export default function Providers({ children }: { children: React.ReactNode }) { const queryClient = getQueryClient() return ( {children} ) } ================================================ FILE: apps/web/src/app/publishing/page.tsx ================================================ import Image from 'next/image' import Link from 'next/link' import Zoom from 'react-medium-image-zoom' import { DotsSection } from '@/components/dots-section' import { ExampleUsageSection } from '@/components/example-usage-section' import { GitHubStarCounter } from '@/components/github-star-counter' import { MCPGatewayFeatures } from '@/components/mcp-gateway-features' import { PageContainer } from '@/components/page-container' import { SupplySideCTA } from '@/components/supply-side-cta' import { githubUrl, twitterUrl } from '@/lib/config' import mcpGatewayDemo from '@/public/agentic-mcp-gateway-mvp-diagram-light.png' export default function PublishingMCPsPage() { return ( {/* Hero section */}

Your API → Paid MCP, Instantly

Run one command to turn any MCP server or OpenAPI service into a paid MCP product. With built-in support for every major LLM SDK and MCP client.
{/* How it works section */}

How It Works

MCP Gateway Demo

Deploy any MCP server or OpenAPI service to Agentic's MCP Gateway, which handles auth, billing, rate-limiting, caching, etc. And instantly turn your API into a paid MCP product that supports every major LLM SDK and MCP client.

{/* Example usage section */} {/* Features section */}

Production-Ready MCP Gateway

{/* Open source section */}

Agentic is 100% Open Source

Agentic is a fully OSS{' '} TypeScript project with a small but vibrant developer community.{' '} Check out the source on GitHub {' '} or{' '} ping me on Twitter with questions / feedback .

{/* CTA section */}

Deploy Your MCP Today

) } ================================================ FILE: apps/web/src/app/signup/page.tsx ================================================ import { Suspense } from 'react' import { PageContainer } from '@/components/page-container' import { SignupForm } from './signup-form' export default function Page() { return ( ) } ================================================ FILE: apps/web/src/app/signup/signup-form.tsx ================================================ 'use client' import { sanitizeSearchParams } from '@agentic/platform-core' import { isValidEmail, isValidPassword, isValidUsername } from '@agentic/platform-validators' import { useForm } from '@tanstack/react-form' import { Loader2Icon } from 'lucide-react' import { useRouter } from 'next/navigation' import { useCallback, useState } from 'react' import { z } from 'zod' import { useNextUrl, useUnauthenticatedAgentic } from '@/components/agentic-provider' import { Button } from '@/components/ui/button' import { Input } from '@/components/ui/input' import { Label } from '@/components/ui/label' import { GitHubIcon } from '@/icons/github' import { toastError } from '@/lib/notifications' import { cn } from '@/lib/utils' export function SignupForm() { const ctx = useUnauthenticatedAgentic() const nextUrl = useNextUrl() const router = useRouter() const [isGitHubLoading, setIsGitHubLoading] = useState(false) const onAuthWithGitHub = useCallback(async () => { setIsGitHubLoading(true) try { const redirectUri = `${globalThis.location.origin}/auth/github/success?${sanitizeSearchParams({ next: nextUrl }).toString()}` const url = await ctx!.api.initAuthFlowWithGitHub({ redirectUri }) return router.push(url) } catch (err: any) { setIsGitHubLoading(false) void toastError(err, { label: 'GitHub auth error' }) } }, [ctx, nextUrl, router]) const form = useForm({ defaultValues: { email: '', username: '', password: '', repeat: '' }, validators: { onChange: z.object({ email: z .string() .email() .refine((email) => isValidEmail(email)), username: z.string().refine((username) => isValidUsername(username)), password: z.string().refine((password) => isValidPassword(password)), repeat: z.string().refine((password) => isValidPassword(password)) }) }, onSubmit: async ({ value }) => { try { if (value.password !== value.repeat) { void toastError('Passwords do not match', { label: 'signup error' }) return } const res = await ctx!.api.signUpWithPassword({ email: value.email, username: value.username, password: value.password }) console.log('signup success', res) } catch (err: any) { void toastError(err, { label: 'signup error' }) return } return router.push(nextUrl || '/app') } }) return (
{ e.preventDefault() void form.handleSubmit() }} >

Create an account

(
field.handleChange(e.target.value)} />
)} /> (
field.handleChange(e.target.value)} />
)} /> (
field.handleChange(e.target.value)} />
)} /> (
field.handleChange(e.target.value)} />
)} /> [ state.canSubmit, state.isSubmitting, state.isTouched ]} children={([canSubmit, isSubmitting, isTouched]) => ( )} />
Or continue with
{ e.preventDefault() void onAuthWithGitHub() }} >
Already have an account?{' '} Login
) } ================================================ FILE: apps/web/src/app/terms/page.tsx ================================================ import Link from 'next/link' import { Markdown } from '@/components/markdown' import { PageContainer } from '@/components/page-container' const lastUpdatedDate = 'June 30, 2025' export default function AboutPage() { return (

Terms of Service

Last updated: {lastUpdatedDate}

1. Introduction

These Terms of Service ("Terms") govern your access to and use of Agentic Systems, Inc.'s websites, software, and related services (collectively, the "Service"). Agentic ("Agentic," "we," "us," or{' '} "our") provides three distinct but connected offerings:

(a) Agentic Marketplace – A curated app store of LLM tool products exposed via both Model Context Protocol (MCP) and standard HTTP APIs.

(b) Agentic Gateway – A fully managed MCP gateway that enables developers to deploy and monetize their own MCP or OpenAPI products, whether privately or publicly.

(c) Agentic Open-Source Project – The source-available software released under the GNU AGPL-3.0 license.

By creating an account, clicking "I agree," or otherwise using any part of the Service, you acknowledge that you have read, understood, and agree to be legally bound by these Terms. If you do not agree, you must not access or use the Service.

2. Eligibility & Account Registration

You must be at least 18 years old and legally capable of entering into contracts to use the Service. When you register an Agentic account you agree to (i) provide accurate, current, and complete information; (ii) maintain the security of your credentials; and (iii) promptly update your information as necessary. You are responsible for all activity occurring under your account.

3. Plans, Subscriptions & Fees

Pricing for Marketplace purchases and Gateway subscriptions is described on the applicable order page or pricing dashboard. All charges are processed by Stripe and are due within the payment period stated at checkout. Except as required by law, payments are non-refundable. We may modify our pricing with at least 30 days' notice, which will take effect in your next billing cycle. Developers publishing products to the Agentic Marketplace must also agree to the{' '} Stripe Connect Account Agreement .

4. Marketplace-Specific Terms

(a) API Consumers. When you purchase access to a product in the Marketplace you receive a non-exclusive, non-transferable, revocable license to call that API subject to any usage limits and other terms displayed on the product page.

(b) API Providers. If you list a product in the Marketplace you (i) represent that you have all rights necessary to offer the product; (ii) grant each purchaser the license described above; and (iii) authorize Agentic to collect payments on your behalf and remit amounts owed to you, less any platform fees.

5. Gateway-Specific Terms

You may deploy private or public APIs through the Gateway. You are solely responsible for the security, legality, and performance of the APIs you deploy. If you enable billing, you appoint Agentic as your limited payments collection agent for the purpose of accepting payments from end users via Stripe.

6. Open-Source Project

The Agentic open-source codebase is licensed under the{' '} GNU AGPL-3.0 license . Your use of the open-source project is governed solely by that license. Nothing in these Terms will be interpreted to limit your rights granted under the AGPL-3.0, nor to grant additional rights beyond it.

7. User Content

"User Content" means any code, text, data, or other materials you upload to the Service, including APIs, metadata, and documentation. You retain all ownership rights in your User Content. You hereby grant Agentic a worldwide, non-exclusive, royalty-free license to host, cache, reproduce, display, perform, modify (solely for technical purposes, e.g. formatting), and distribute your User Content as necessary to operate and improve the Service. You are solely responsible for your User Content and represent that you have all rights necessary to grant this license and that your User Content does not violate any law or third-party rights.

8. Acceptable Use Policy

You agree not to (i) violate applicable laws; (ii) infringe the intellectual-property or privacy rights of others; (iii) transmit malicious code; (iv) attempt to gain unauthorized access to the Service; (v) interfere with the integrity or performance of the Service; or (vi) send spam or engage in fraudulent or deceptive practices. We may suspend or terminate accounts that violate this policy.

9. Privacy & Security

Our collection and use of personal information is described in our Privacy Policy. We implement appropriate technical and organizational measures to safeguard your data; however, no security measure is perfect and we cannot guarantee absolute security.

10. Intellectual Property

The Service, including all associated software, content, and trademarks, is owned by Agentic or its licensors and is protected by intellectual-property laws. Except for the rights expressly granted to you in these Terms, we reserve all rights, title, and interest in the Service.

11. Suspension & Termination

We may suspend or terminate your access to the Service at any time if we believe you have violated these Terms or if necessary to protect the Service or its users. Upon termination, your right to use the Service will cease immediately, but Sections 6–16 will survive.

12. Disclaimers

THE SERVICE IS PROVIDED "AS IS" AND "AS AVAILABLE" WITHOUT WARRANTY OF ANY KIND. TO THE MAXIMUM EXTENT PERMITTED BY LAW, AGENTIC DISCLAIMS ALL WARRANTIES, WHETHER EXPRESS, IMPLIED, STATUTORY, OR OTHERWISE, INCLUDING MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE, AND NON-INFRINGEMENT. WE DO NOT WARRANT THAT THE SERVICE WILL BE UNINTERRUPTED, ERROR-FREE, OR SECURE.

13. Limitation of Liability

TO THE FULLEST EXTENT PERMITTED BY LAW, IN NO EVENT WILL AGENTIC BE LIABLE FOR ANY INDIRECT, INCIDENTAL, SPECIAL, CONSEQUENTIAL, OR EXEMPLARY DAMAGES (INCLUDING LOSS OF PROFITS, GOODWILL, OR DATA) ARISING OUT OF OR IN CONNECTION WITH THE SERVICE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGES. AGENTIC'S TOTAL LIABILITY UNDER THESE TERMS WILL NOT EXCEED THE GREATER OF (A) FEES YOU PAID TO AGENTIC IN THE 12 MONTHS PRECEDING THE EVENT GIVING RISE TO THE CLAIM OR (B) USD 100.

14. Indemnification

You will indemnify and hold harmless Agentic and its officers, directors, employees, and agents from and against any third-party claims, damages, and expenses (including reasonable attorneys' fees) arising out of or related to your (i) breach of these Terms, (ii) User Content, or (iii) violation of any law or third-party rights.

15. Governing Law & Venue

These Terms are governed by the laws of the State of Delaware, excluding its conflict-of-laws rules. The state and federal courts located in Wilmington, Delaware will have exclusive jurisdiction to adjudicate any dispute arising out of or relating to these Terms or the Service, and you consent to personal jurisdiction and venue in those courts.

16. Changes to These Terms

We may update these Terms by posting a revised version on our website and providing notice via email or in-app notification at least 7 days before the effective date. Continued use of the Service after the effective date constitutes acceptance of the revised Terms.

17. Contact

Questions or notices required under these Terms should be sent to support@agentic.so. You may also contact us through our website.

) } ================================================ FILE: apps/web/src/components/active-link.tsx ================================================ 'use client' import cs from 'clsx' import Link, { type LinkProps } from 'next/link' import { usePathname } from 'next/navigation' import * as React from 'react' type ActiveLinkProps = LinkProps & { children?: React.ReactNode className?: string activeClassName?: string style?: React.CSSProperties // optional comparison function to normalize URLs before comparing compare?: (a?: any, b?: any) => boolean } /** * Link that will be disabled if the target `href` is the same as the current * route's pathname. */ export const ActiveLink = React.forwardRef(function ActiveLink( { children, href, style, className, activeClassName, onClick, prefetch, compare = (a, b) => a === b, ...props }: ActiveLinkProps, ref ) { const pathname = usePathname() const [disabled, setDisabled] = React.useState(false) React.useEffect(() => { const currentUrl = new URL(location.href) const url = new URL(href as string, currentUrl) const linkPathname = url.pathname const linkOrigin = url.origin setDisabled( compare(linkPathname, pathname) && compare(linkOrigin, currentUrl.origin) ) }, [pathname, href, compare]) const styleOverride = React.useMemo( () => disabled ? { ...style, pointerEvents: 'none' } : (style ?? {}), [disabled, style] ) const onClickOverride = React.useCallback( (event: any): void => { if (disabled) { event.preventDefault() return } if (onClick) { onClick(event) return } }, [disabled, onClick] ) return ( {children} ) }) ================================================ FILE: apps/web/src/components/agentic-provider.tsx ================================================ 'use client' import { AgenticApiClient, type AuthSession } from '@agentic/platform-api-client' import { sanitizeSearchParams } from '@agentic/platform-core' import { usePathname, useRouter, useSearchParams } from 'next/navigation' import { createContext, type ReactNode, useCallback, useContext, useEffect, useState } from 'react' import { useLocalStorage } from 'react-use' import * as config from '@/lib/config' export type AgenticContextType = { api: AgenticApiClient isAuthenticated: boolean logout: () => void } const AgenticContext = createContext(undefined) export function AgenticProvider({ children }: { children: ReactNode }) { const [authSession, setAuthSession] = useLocalStorage( 'agentic-auth-session' ) const logout = useCallback(() => { setAuthSession(null) }, [setAuthSession]) const onUpdateAuth = useCallback( (updatedAuthSession?: AuthSession | null) => { // console.log('onUpdateAuth', { // authSession: structuredClone(authSession), // updatedAuthSession: structuredClone(updatedAuthSession), // isCurrentlyAuthenticated: agenticContext.isAuthenticated // }) if ( !!authSession !== !!updatedAuthSession || authSession?.token !== updatedAuthSession?.token || agenticContext.isAuthenticated !== !!updatedAuthSession ) { setAuthSession(updatedAuthSession) } }, // eslint-disable-next-line react-hooks/exhaustive-deps [authSession, setAuthSession] ) const [agenticContext, setAgenticContext] = useState({ api: new AgenticApiClient({ apiBaseUrl: config.apiBaseUrl, onUpdateAuth }), isAuthenticated: !!authSession, logout }) useEffect(() => { // console.log('updating session from localStorage', authSession?.token) if (authSession) { // console.log('setting auth session to truthy', { // authSession: structuredClone(authSession), // isAuthenticated: agenticContext.isAuthenticated, // setAgenticContext: !agenticContext.isAuthenticated // }) agenticContext.api.authSession = authSession if (!agenticContext.isAuthenticated) { setAgenticContext({ ...agenticContext, isAuthenticated: true }) } } else { // console.log('setting auth session to falsy', { // authSession: structuredClone(authSession), // isAuthenticated: agenticContext.isAuthenticated, // setAgenticContext: !!agenticContext.isAuthenticated // }) agenticContext.api.authSession = undefined if (agenticContext.isAuthenticated) { setAgenticContext({ ...agenticContext, isAuthenticated: false }) } } }, [agenticContext, authSession]) return ( {children} ) } export function useAgentic(): AgenticContextType | undefined { const ctx = useContext(AgenticContext) const [isMounted, setIsMounted] = useState(false) useEffect(() => { if (!isMounted) { setIsMounted(true) return } }, [isMounted, setIsMounted]) if (!ctx) { throw new Error('useAgentic must be used within an AgenticProvider') } return isMounted ? ctx : undefined } export function useUnauthenticatedAgentic(): AgenticContextType | undefined { const ctx = useAgentic() const nextUrl = useNextUrl() || '/app' const router = useRouter() if (ctx && ctx.isAuthenticated) { console.log('REQUIRES NO AUTHENTICATION: redirecting to', nextUrl) void router.replace(nextUrl) return } return ctx } export function useAuthenticatedAgentic(): AgenticContextType | undefined { const ctx = useAgentic() const pathname = usePathname() const router = useRouter() if (ctx && !ctx.isAuthenticated) { if (pathname === '/logout') { console.log('LOGOUT SUCCESS: redirecting to /') void router.replace('/') return } console.log('REQUIRES AUTHENTICATION: redirecting to /login', { next: pathname }) void router.replace( `/login?${sanitizeSearchParams({ next: pathname }).toString()}` ) return } return ctx } export function useNextUrl(): string | undefined { const searchParams = useSearchParams() const [isMounted, setIsMounted] = useState(false) useEffect(() => { if (!isMounted) { setIsMounted(true) return } }, [isMounted, setIsMounted]) return isMounted ? (searchParams.get('next') ?? undefined) : undefined } ================================================ FILE: apps/web/src/components/app-consumers-list.tsx ================================================ 'use client' import Link from 'next/link' import useInfiniteScroll from 'react-infinite-scroll-hook' import { useAuthenticatedAgentic } from '@/components/agentic-provider' import { LoadingIndicator } from '@/components/loading-indicator' import { useInfiniteQuery } from '@/lib/query-client' export function AppConsumersList() { const ctx = useAuthenticatedAgentic() const limit = 10 const { data, isLoading, isError, hasNextPage, fetchNextPage, isFetchingNextPage } = useInfiniteQuery({ queryKey: ['consumers', ctx?.api.authSession?.user?.id], queryFn: ({ pageParam = 0 }) => ctx!.api .listConsumers({ populate: ['project'], offset: pageParam, limit }) .then(async (consumers) => { return { consumers, offset: pageParam, limit, nextOffset: consumers.length >= limit ? pageParam + consumers.length : undefined } }), getNextPageParam: (lastGroup) => lastGroup?.nextOffset, enabled: !!ctx, initialPageParam: 0 }) const [sentryRef] = useInfiniteScroll({ loading: isLoading || isFetchingNextPage, hasNextPage, onLoadMore: fetchNextPage, disabled: !ctx || isError, rootMargin: '0px 0px 200px 0px' }) const consumers = data ? data.pages.flatMap((p) => p.consumers) : [] const numConsumers = consumers.length return ( <> {!ctx || isLoading ? ( ) : (

Your Subscriptions

{isError ? (

Error fetching customer subscriptions

) : !consumers.length ? (

No subscriptions found.{' '} Subscribe to your first project to get started.

) : (
{consumers.map((consumer) => (

{consumer.project.name}

{consumer.project.identifier}

                    {JSON.stringify(consumer, null, 2)}
                  
))} {hasNextPage && (
{isLoading || (isFetchingNextPage && )}
)}
)}
)} ) } ================================================ FILE: apps/web/src/components/app-projects-list.tsx ================================================ 'use client' import Link from 'next/link' import useInfiniteScroll from 'react-infinite-scroll-hook' import { useAuthenticatedAgentic } from '@/components/agentic-provider' import { LoadingIndicator } from '@/components/loading-indicator' import { useInfiniteQuery } from '@/lib/query-client' export function AppProjectsList() { const ctx = useAuthenticatedAgentic() const limit = 10 const { data, isLoading, isError, hasNextPage, fetchNextPage, isFetchingNextPage } = useInfiniteQuery({ queryKey: ['projects', ctx?.api.authSession?.user?.id], queryFn: ({ pageParam = 0 }) => ctx!.api .listProjects({ populate: ['lastPublishedDeployment'], offset: pageParam, limit }) .then(async (projects) => { return { projects, offset: pageParam, limit, nextOffset: projects.length >= limit ? pageParam + projects.length : undefined } }), getNextPageParam: (lastGroup) => lastGroup?.nextOffset, enabled: !!ctx, initialPageParam: 0 }) const [sentryRef] = useInfiniteScroll({ loading: isLoading || isFetchingNextPage, hasNextPage, onLoadMore: fetchNextPage, disabled: !ctx || isError, rootMargin: '0px 0px 200px 0px' }) const projects = data ? data.pages.flatMap((p) => p.projects) : [] return ( <> {!ctx || isLoading ? ( ) : (

Your Projects

{isError ? (

Error fetching projects

) : !projects.length ? (

No projects found.{' '} Create your first project to get started.

) : (
{projects.map((project) => (

{project.name}

{project.identifier}

{project.lastPublishedDeployment && (

Last published:{' '} {project.lastPublishedDeployment.version || project.lastPublishedDeployment.hash}

)} ))} {hasNextPage && (
{isLoading || (isFetchingNextPage && )}
)}
)}
)} ) } ================================================ FILE: apps/web/src/components/bootstrap.tsx ================================================ 'use client' import { useFirstMountState } from 'react-use' import { bootstrap } from '@/lib/bootstrap' export function Bootstrap() { const isFirstMount = useFirstMountState() if (isFirstMount) { bootstrap() } // Return `null` so we can use this as a react component return null } ================================================ FILE: apps/web/src/components/code-block/highlight.ts ================================================ import { toJsxRuntime } from 'hast-util-to-jsx-runtime' import { Fragment, type JSX } from 'react' import { jsx, jsxs } from 'react/jsx-runtime' import { type BundledLanguage, codeToHast } from 'shiki/bundle/web' import { cn } from '@/lib/utils' // TODO: consider adding [twoslash](https://shiki.style/packages/twoslash) export async function highlight({ code, lang = 'ts', theme = 'github-dark', className }: { code: string lang?: BundledLanguage theme?: string className?: string }): Promise { className = cn( 'w-full text-wrap p-2 md:p-4 text-sm rounded-sm overflow-x-auto', className ) const hast = await codeToHast(code, { lang, theme, // TODO: use a custom `pre` element down below instead of this transformers: [ { pre(node) { if (className) { this.addClassToHast(node, className) } } } ] }) return toJsxRuntime(hast, { Fragment, jsx, jsxs }) } ================================================ FILE: apps/web/src/components/code-block/index.tsx ================================================ 'use client' import { CheckIcon, CopyIcon } from 'lucide-react' import { type JSX, useCallback, useEffect, useRef, useState } from 'react' import { type BundledLanguage } from 'shiki/bundle/web' import { LoadingIndicator } from '@/components/loading-indicator' import { Button } from '@/components/ui/button' import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip' import { toastError } from '@/lib/notifications' import { cn } from '@/lib/utils' import { highlight } from './highlight' export function CodeBlock({ initial, code, lang, theme, className }: { initial?: JSX.Element code: string lang?: BundledLanguage theme?: string className?: string }) { const [nodes, setNodes] = useState(initial) const [isCopied, setIsCopied] = useState(false) const timeoutRef = useRef | null>(null) useEffect(() => { void highlight({ code, lang, theme }).then(setNodes) }, [code, lang, theme]) const onCopy = useCallback(() => { ;(async () => { try { await navigator.clipboard.writeText(code) setIsCopied(true) if (timeoutRef.current) { clearTimeout(timeoutRef.current) timeoutRef.current = null } timeoutRef.current = setTimeout(() => { timeoutRef.current = null setIsCopied(false) }, 2000) } catch { setIsCopied(true) if (timeoutRef.current) { clearTimeout(timeoutRef.current) timeoutRef.current = null } void toastError('Error copying code to clipboard') } })() }, [code, timeoutRef]) const numNewLines = code.split('\n').length return (
{nodes ? ( <> {nodes} {isCopied ? Copied : Copy to clipboard} ) : ( )}
) } ================================================ FILE: apps/web/src/components/confetti.tsx ================================================ 'use client' import type { Simplify } from 'type-fest' import confetti, { type Options as ConfettiBaseOptions } from 'canvas-confetti' import { useCallback } from 'react' import { randomInRange } from '@/lib/utils' export type ConfettiOptions = Simplify< ConfettiBaseOptions & { duration?: number intervalMs?: number } > // TODO: make shoot in from the sides export function useConfettiFireworks() { const fireConfetti = useCallback((options: ConfettiOptions = {}) => { const duration = options.duration ?? 4 * 1000 const intervalMs = options.intervalMs ?? 250 const animationEnd = Date.now() + duration const defaults: ConfettiOptions = { startVelocity: 30, spread: 360, ticks: 60, zIndex: 100, ...options } const interval = globalThis.window.setInterval(() => { const timeLeft = animationEnd - Date.now() if (timeLeft <= 0) { return globalThis.clearInterval(interval) } const particleCount = 50 * (timeLeft / duration) void confetti({ particleCount, origin: { x: randomInRange(0.1, 0.3), y: Math.random() - 0.2 }, ...defaults }) void confetti({ particleCount, origin: { x: randomInRange(0.7, 0.9), y: Math.random() - 0.2 }, ...defaults }) }, intervalMs) return () => { globalThis.clearInterval(interval) } }, []) return { fireConfetti } } ================================================ FILE: apps/web/src/components/dark-mode-toggle.tsx ================================================ 'use client' import { Moon, Sun } from 'lucide-react' import { useTheme } from 'next-themes' import * as React from 'react' import { Button } from '@/components/ui/button' import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip' import { cn } from '@/lib/utils' export function DarkModeToggle({ className }: { className?: string }) { const { setTheme, resolvedTheme } = useTheme() return ( Toggle dark mode ) } ================================================ FILE: apps/web/src/components/demand-side-cta.tsx ================================================ import Link from 'next/link' import { HeroButton } from '@/components/hero-button' import { Button } from '@/components/ui/button' export function DemandSideCTA() { return (
gotoTools();
) } ================================================ FILE: apps/web/src/components/dots-section.tsx ================================================ import { cn } from '@/lib/utils' export function DotsSection({ children, className }: { children: React.ReactNode className?: string }) { return (
{children}
) } ================================================ FILE: apps/web/src/components/example-agentic-configs.tsx ================================================ 'use client' import type { BundledLanguage } from 'shiki/bundle/web' import { useLocalStorage } from 'react-use' import { CodeBlock } from '@/components/code-block' import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs' import { LoadingIndicator } from './loading-indicator' const formatLabels = { typescript: 'TypeScript', json: 'JSON' } as const const formats = Object.keys(formatLabels) as (keyof typeof formatLabels)[] type Format = (typeof formats)[number] const originAdaptorLabels = { mcp: 'MCP', openapi: 'OpenAPI' } as const const originAdaptors = Object.keys( originAdaptorLabels ) as (keyof typeof originAdaptorLabels)[] type OriginAdaptor = (typeof originAdaptors)[number] type ExampleAgenticConfig = { format: Format originAdaptor: OriginAdaptor } const defaultExampleAgenticConfig: ExampleAgenticConfig = { format: 'typescript', originAdaptor: 'mcp' } type CodeSnippet = { code: string lang: BundledLanguage } export function ExampleAgenticConfigs() { const [config, setConfig] = useLocalStorage( 'example-agentic-config', defaultExampleAgenticConfig ) if (!config) { return } return (
) } function ExampleAgenticConfigsContent({ config, setConfig }: { config: ExampleAgenticConfig setConfig: (config: ExampleAgenticConfig) => void }) { const codeSnippet = getCodeSnippetForExampleAgenticConfig(config) return ( setConfig({ ...defaultExampleAgenticConfig, ...config, format: value as Format }) } className='w-full max-w-3xl' > {formats.map((format) => ( {formatLabels[format]} ))} setConfig({ ...defaultExampleAgenticConfig, ...config, originAdaptor: value as OriginAdaptor }) } className='w-full' > {originAdaptors.map((originAdaptor) => ( {originAdaptorLabels[originAdaptor]} ))} {originAdaptors.map((originAdaptor) => (
{config.format === 'typescript' ? 'agentic.config.ts' : 'agentic.config.json'}
))}
) } function getCodeSnippetForExampleAgenticConfig( config: ExampleAgenticConfig ): CodeSnippet { if (config.format === 'typescript') { if (config.originAdaptor === 'mcp') { return { code: ` import { defineConfig } from '@agentic/platform' export default defineConfig({ name: 'mcp-example', origin: { type: 'mcp', // Your origin MCP server URL supporting the streamable HTTP transport url: '' } })`.trim(), lang: 'typescript' } } else { return { code: ` import { defineConfig } from '@agentic/platform' export default defineConfig({ name: 'openapi-example', origin: { type: 'openapi', // Your origin OpenAPI server base URL url: '', // Your origin OpenAPI spec path or URL spec: '' } })`.trim(), lang: 'typescript' } } } else if (config.format === 'json') { if (config.originAdaptor === 'mcp') { return { code: ` { "$schema": "https://agentic.so/schema.json", "name": "mcp-example", "origin": { "type": "mcp", "url": "" } }`.trim(), lang: 'json' } } else { return { code: ` { "$schema": "https://agentic.so/schema.json", "name": "openapi-example", "origin": { "type": "openapi", "url": "", "spec": "" } }`.trim(), lang: 'json' } } } return { code: '', lang: 'json' } } ================================================ FILE: apps/web/src/components/example-usage-section.tsx ================================================ import Link from 'next/link' import { highlight } from '@/components/code-block/highlight' import { ExampleUsage } from '@/components/example-usage' import { defaultConfig, getCodeForDeveloperConfig } from '@/lib/developer-config' import { globalAgenticApiClient } from '@/lib/global-api' export async function ExampleUsageSection() { const projectIdentifier = '@agentic/search' const tool = 'search' // TODO: use prefetching here const initialProject = await globalAgenticApiClient.getPublicProjectByIdentifier({ projectIdentifier, populate: ['lastPublishedDeployment'] }) // TODO: this should be loaded in `ExampleUsage` const initialCodeSnippet = getCodeForDeveloperConfig({ config: defaultConfig, project: initialProject, deployment: initialProject.lastPublishedDeployment!, identifier: projectIdentifier, tool }) const initialCodeBlock = await highlight(initialCodeSnippet) return (

Agentic tools that{' '} work everywhere

This example uses the{' '} {projectIdentifier} {' '} tool to provide an LLM access to the web.

All Agentic tools are exposed as both{' '} MCP servers as well as simple{' '} HTTP APIs. MCP is important for interop and future-proofing, whereas simple HTTP POST requests make tool use easy to debug and simplifies usage with LLM tool calling.

) } ================================================ FILE: apps/web/src/components/example-usage.tsx ================================================ 'use client' import type { Project } from '@agentic/platform-types' import Link from 'next/link' import { type JSX, useEffect, useMemo, useState } from 'react' import { useLocalStorage } from 'react-use' import { useAgentic } from '@/components/agentic-provider' import { CodeBlock } from '@/components/code-block' import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs' import { type CodeSnippet, defaultConfig, type DeveloperConfig, getCodeForDeveloperConfig, type HTTPTarget, httpTargetLabels, httpTargets, type MCPClientTarget, mcpClientTargetLabels, mcpClientTargets, type PyFrameworkTarget, pyFrameworkTargetLabels, pyFrameworkTargets, type Target, targetLabels, targets, type TsFrameworkTarget, tsFrameworkTargetLabels, tsFrameworkTargets } from '@/lib/developer-config' import { useQuery } from '@/lib/query-client' import { LoadingIndicator } from './loading-indicator' import { Button } from './ui/button' export function ExampleUsage({ projectIdentifier, project: initialProject, tool, apiKey, initialCodeBlock }: { projectIdentifier: string project?: Project tool?: string apiKey?: string initialCodeBlock?: JSX.Element }) { const ctx = useAgentic() const [isMounted, setIsMounted] = useState(false) useEffect(() => { setIsMounted(true) }, []) const [rawConfig, setConfig] = useLocalStorage( 'developer-config', defaultConfig ) const config = useMemo( () => (isMounted && rawConfig ? rawConfig : defaultConfig), [rawConfig, isMounted] ) // TODO: make this configurable // TODO: allow to take the project and/or consumer in as props // TODO: need a way of fetching a project and target deployment; same as in `AgenticToolClient.fromIdentifier` (currently only supports latest) // Load the public project const { data: project, isLoading, isError } = useQuery({ queryKey: ['public-project', projectIdentifier], queryFn: () => ctx!.api.getPublicProjectByIdentifier({ projectIdentifier, populate: ['lastPublishedDeployment'] }), enabled: !!ctx, initialData: initialProject }) // If the user is authenticated, check if they have an active subscription to // this project // TODO: use consumer for apiKey // const { // data: consumer, // isLoading: isConsumerLoading // // isError: isConsumerError // } = useQuery({ // queryKey: [ // 'project', // projectIdentifier, // 'user', // ctx?.api.authSession?.user.id // ], // queryFn: () => // ctx!.api.getConsumerByProjectIdentifier({ // projectIdentifier // }), // enabled: !!ctx?.isAuthenticated // }) return (
) } function ExampleUsageContent({ projectIdentifier, tool, apiKey, initialCodeBlock, isLoading, isError, project, config, setConfig }: { projectIdentifier: string tool?: string apiKey?: string initialCodeBlock?: JSX.Element isLoading: boolean isError: boolean project?: Project config?: DeveloperConfig setConfig: (config: DeveloperConfig) => void }) { if (isLoading || !config) { return } // TODO: allow to target a specific deployment const deployment = project?.lastPublishedDeployment if (isError || !project || !deployment) { return (
Error loading project. Please refresh the page or contact{' '} support@agentic.so.
) } const codeSnippet = getCodeForDeveloperConfig({ config, project, deployment, identifier: projectIdentifier, tool, apiKey }) return ( setConfig({ ...defaultConfig, ...config, target: value as Target }) } className='w-full' > {targets.map((target) => ( {targetLabels[target]} ))} setConfig({ ...defaultConfig, ...config, mcpClientTarget: value as MCPClientTarget }) } className='w-full' > {mcpClientTargets.map((mcpClientTarget) => ( {mcpClientTargetLabels[mcpClientTarget]} ))} {mcpClientTargets.map((mcpClientTarget) => ( ))} setConfig({ ...defaultConfig, ...config, tsFrameworkTarget: value as TsFrameworkTarget }) } className='w-full' > {tsFrameworkTargets.map((framework) => ( {tsFrameworkTargetLabels[framework]} ))} {tsFrameworkTargets.map((framework) => ( ))} setConfig({ ...defaultConfig, ...config, pyFrameworkTarget: value as PyFrameworkTarget }) } className='w-full' > {pyFrameworkTargets.map((framework) => ( {pyFrameworkTargetLabels[framework]} ))} {pyFrameworkTargets.map((framework) => ( ))} setConfig({ ...defaultConfig, ...config, httpTarget: value as HTTPTarget }) } className='w-full' > {httpTargets.map((httpTarget) => ( {httpTargetLabels[httpTarget]} ))} {httpTargets.map((httpTarget) => ( ))} ) } function CodeSnippetBlock({ codeSnippet, initialCodeBlock }: { codeSnippet: CodeSnippet initialCodeBlock?: JSX.Element }) { return ( <> {codeSnippet.action && ( )} ) } ================================================ FILE: apps/web/src/components/feature.tsx ================================================ 'use client' import type { ComponentType, ReactNode } from 'react' import { motion, type MotionValue, useMotionTemplate, useMotionValue } from 'motion/react' import Link from 'next/link' import { GridPattern } from './grid-pattern' export type FeatureData = { name: string description: ReactNode icon: ComponentType<{ className?: string }> pattern: Omit href?: string } export function Feature({ name, description, icon, pattern, href }: FeatureData) { const mouseX = useMotionValue(0) const mouseY = useMotionValue(0) function onMouseMove({ currentTarget, clientX, clientY }: React.MouseEvent) { const { left, top } = currentTarget.getBoundingClientRect() mouseX.set(clientX - left) mouseY.set(clientY - top) } const content = ( <>

{/* */} {name}

{description}

) const className = 'dark:bg-white/2.5 bg-gray-50 hover:shadow-gray-900/5 group relative flex rounded-2xl transition-shadow hover:shadow-md dark:hover:shadow-black/5' if (href) { return ( {content} ) } else { return (
{content}
) } } // eslint-disable-next-line @typescript-eslint/naming-convention function FeatureIcon({ icon: Icon }: { icon: FeatureData['icon'] }) { return (
) } function FeaturePattern({ mouseX, mouseY, ...gridProps }: FeatureData['pattern'] & { mouseX: MotionValue mouseY: MotionValue }) { const maskImage = useMotionTemplate`radial-gradient(180px at ${mouseX}px ${mouseY}px, white, transparent)` const style = { maskImage, WebkitMaskImage: maskImage } return (
) } ================================================ FILE: apps/web/src/components/footer/dynamic.tsx ================================================ 'use client' import { ActiveLink } from '@/components/active-link' import { useAgentic } from '@/components/agentic-provider' export function DynamicFooter() { const ctx = useAgentic() return ( <> {ctx?.isAuthenticated ? ( <>
Dashboard
Logout
) : ( <>
Log in
Sign up
)} ) } ================================================ FILE: apps/web/src/components/footer/index.tsx ================================================ import Link from 'next/link' import { ActiveLink } from '@/components/active-link' import { GitHubIcon } from '@/icons/github' import { TwitterIcon } from '@/icons/twitter' import { copyright, githubUrl, twitterUrl } from '@/lib/config' import { DynamicFooter } from './dynamic' export function Footer() { return (

Platform

Resources

Company

Social

{copyright}
) } ================================================ FILE: apps/web/src/components/github-star-counter.tsx ================================================ 'use client' import NumberFlow from '@number-flow/react' import Link from 'next/link' import { type ReactNode, useEffect, useState } from 'react' import { GitHubIcon } from '@/icons/github' import { githubUrl } from '@/lib/config' import { cn } from '@/lib/utils' import { Button } from './ui/button' // TODO: fetch this dynamically const numGitHubStars = 17_600 export function GitHubStarCounter({ className, children }: { className?: string children?: ReactNode }) { const [numStars, setNumStars] = useState(0) useEffect(() => { setNumStars(numGitHubStars) }, []) return ( ) } ================================================ FILE: apps/web/src/components/grid-pattern.tsx ================================================ import { type SVGProps, useId } from 'react' export type GridPattern = Omit< SVGProps, 'width' | 'height' | 'x' | 'y' > & { width: number height: number x: string | number y: string | number squares: Array<[x: number, y: number]> } export function GridPattern({ width, height, x, y, squares, ...props }: GridPattern) { const patternId = useId() return ( ) } ================================================ FILE: apps/web/src/components/header/authenticated-header.tsx ================================================ 'use client' import { useState } from 'react' import { ActiveLink } from '@/components/active-link' import { useAgentic } from '@/components/agentic-provider' import { Avatar, AvatarFallback, AvatarImage } from '@/components/ui/avatar' import { DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuSeparator, DropdownMenuTrigger } from '@/components/ui/dropdown-menu' export function AuthenticatedHeader() { const ctx = useAgentic() const [isDropdownMenuOpen, setIsDropdownMenuOpen] = useState(false) if (!ctx?.isAuthenticated) { return null } return ( {ctx.api.authSession!.user.username?.slice(0, 2).toUpperCase()} setIsDropdownMenuOpen(false)}> Dashboard setIsDropdownMenuOpen(false)} > My Projects setIsDropdownMenuOpen(false)} > My Subscriptions setIsDropdownMenuOpen(false)} > Logout ) } ================================================ FILE: apps/web/src/components/header/index.tsx ================================================ import Link from 'next/link' import { ActiveLink } from '@/components/active-link' import { DarkModeToggle } from '@/components/dark-mode-toggle' import { Button } from '@/components/ui/button' import { GitHubIcon } from '@/icons/github' import { githubUrl } from '@/lib/config' import { cn } from '@/lib/utils' import { AuthenticatedHeader } from './authenticated-header' import styles from './styles.module.css' import { UnauthenticatedHeader } from './unauthenticated-header' export function Header() { return (
AGENTIC AGENTIC
MCP Marketplace MCP Publishing Docs
) } ================================================ FILE: apps/web/src/components/header/styles.module.css ================================================ .header { position: sticky; top: 0; left: 0; z-index: 49; width: 100%; max-width: 100vw; overflow: hidden; height: 55px; min-height: 55px; background: hsla(0, 0%, 100%, 0.8); backdrop-filter: saturate(180%) blur(16px); display: flex; flex-direction: column; align-items: center; padding: 12px 12px 0; line-height: 1; } :global(.dark) .header { background: transparent; box-shadow: inset 0 -1px 0 0 rgba(0, 0, 0, 0.1); backdrop-filter: saturate(180%) blur(20px); } .headerContent { align-self: center; width: 100%; max-width: 1200px; display: flex; flex-direction: row; justify-content: space-between; align-items: center; gap: 1em; min-height: 32px; } .navHeader { display: flex; flex-direction: row; justify-content: space-between; align-items: center; gap: var(--gap-w-1); max-width: var(--max-width); height: 100%; margin: 0 auto; } ================================================ FILE: apps/web/src/components/header/unauthenticated-header.tsx ================================================ 'use client' import { ActiveLink } from '@/components/active-link' import { useAgentic } from '@/components/agentic-provider' import { Button } from '@/components/ui/button' export function UnauthenticatedHeader() { const ctx = useAgentic() if (ctx?.isAuthenticated) { return null } return ( <> ) } ================================================ FILE: apps/web/src/components/hero-button/index.tsx ================================================ import type * as React from 'react' import type { Simplify } from 'type-fest' import { Button, type ButtonProps } from '@/components/ui/button' import { cn } from '@/lib/utils' import styles from './styles.module.css' export type HeroButtonVariant = 'orange' | 'blue' | 'purple' export type HeroButtonProps = Simplify< { heroVariant?: HeroButtonVariant className?: string buttonClassName?: string } & ButtonProps > export function HeroButton({ heroVariant = 'purple', className, buttonClassName, ...buttonProps }: HeroButtonProps) { return (
{heroVariant === 'blue' && ( )} {heroVariant === 'purple' && ( )} {heroVariant === 'orange' && ( )}
) } ================================================ FILE: apps/web/src/components/hero-button/styles.module.css ================================================ .heroButtonWrapper { position: relative; max-width: 100%; z-index: 99; } .heroButtonBg1 { --start-color: #00dfd8; --end-color: #007cf0; /* animation: heroBgAnimation1 8s infinite; */ } .heroButtonBg2 { --start-color: #ff0080; --end-color: #7928ca; /* animation: heroBgAnimation2 8s infinite; */ } .heroButtonBg3 { --start-color: #ff4d4d; --end-color: #f9cb28; /* animation: heroBgAnimation3 8s infinite; */ } .heroButtonBg { position: absolute; top: 0; left: 0; width: 100%; height: 100%; background-image: linear-gradient( 165deg, var(--start-color), var(--end-color) ); border-radius: 5px; z-index: -2; } .heroButtonBg::before { position: absolute; content: ''; top: 0; left: 0; right: 0; bottom: 0; z-index: -1; border: 12px solid transparent; filter: blur(24px); /* animation: pulse 2s ease-in-out infinite alternate; */ background-image: linear-gradient( 165deg, var(--start-color), var(--end-color) ); } .heroButton { cursor: pointer; background-color: var(--background); background-clip: padding-box; border: 1px solid transparent; box-shadow: 0 4px 4px 0 #00000010; color: var(--foreground); transition-property: color, background-color, box-shadow; transition-duration: 0.15s; transition-timing-function: ease; padding: 20px 24px; border-radius: 5px; max-width: 100%; display: flex; justify-content: center; align-items: center; user-select: none; outline: none; white-space: nowrap; --lighten-color: hsla(0, 0%, 100%, 0.8); background-image: linear-gradient( to right, var(--lighten-color), var(--lighten-color) ); } :global(.dark) .heroButton { --lighten-color: rgba(0, 0, 0, 0.75); } .heroButton:hover { --lighten-color: transparent; background-color: transparent; color: var(--background); } .heroButton:focus:not(:active):not(:hover) { border-color: var(--foreground); } .heroButton:active { --lighten-color: hsla(0, 0%, 100%, 0.5); } .heroButtonWrapper:has(.heroButton:disabled) { opacity: 0.3; cursor: not-allowed; } .heroButtonWrapper:has(.heroButton:disabled) * { pointer-events: none; } /* @keyframes heroBgAnimation1 { 0%, 16.667%, to { opacity: 1; } 33%, 83.333% { opacity: 0; } } @keyframes heroBgAnimation2 { 0%, 16.667%, 66.667%, to { opacity: 0; } 33.333%, 50% { opacity: 1; } } @keyframes heroBgAnimation3 { 0%, 50%, to { opacity: 0; } 66.667%, 83.333% { opacity: 1; } } */ /* @keyframes pulse { from { filter: blur(8px); } to { filter: blur(32px); } } */ ================================================ FILE: apps/web/src/components/hero-simulation-2.tsx ================================================ 'use client' import { Physics, useSphere } from '@react-three/cannon' import { Environment, useTexture } from '@react-three/drei' import { Canvas, useFrame, useThree } from '@react-three/fiber' import { Bloom, EffectComposer, // N8AO, SMAA, SSAO } from '@react-three/postprocessing' import { useEffect, useState } from 'react' import * as THREE from 'three' const rfs = THREE.MathUtils.randFloatSpread const sphereGeometry = new THREE.SphereGeometry(1, 32, 32) const baubleMaterial = new THREE.MeshStandardMaterial({ color: 'white', roughness: 0, envMapIntensity: 1 }) export function HeroSimulation2({ className }: { className?: string }) { const [hovered, setHovered] = useState(false) // Change cursor on hovered state useEffect(() => { if (hovered) { document.body.style.cursor = 'none' } else { document.body.style.cursor = 'auto' } // document.body.style.cursor = hovered // ? 'none' // : `url('data:image/svg+xml;base64,${btoa( // '' // )}'), auto` }, [hovered]) return ( setHovered(true)} onPointerOut={() => setHovered(false)} > {/* */} ) } function Clump({ mat = new THREE.Matrix4(), vec = new THREE.Vector3(), numBalls = 64 }) { const texture = useTexture('/mcp.png') const [ref, api] = useSphere(() => ({ args: [1], mass: 1, angularDamping: 0.1, linearDamping: 0.65, position: [rfs(20), rfs(20), rfs(20)] })) useFrame((_state, _delta) => { for (let i = 0; i < numBalls; i++) { // Get current whereabouts of the instanced sphere ;(ref.current as any).getMatrixAt(i, mat) // ref.current.children[i]!.getM(i, mat) // Normalize the position and multiply by a negative force. // This is enough to drive it towards the center-point. api .at(i) ?.applyForce( vec .setFromMatrixPosition(mat) .normalize() .multiplyScalar(-40) .toArray(), [0, 0, 0] ) } }) return ( ) } function Pointer() { const viewport = useThree((state) => state.viewport) const [ref, api] = useSphere(() => ({ type: 'Kinematic', args: [3], position: [0, 0, 0] })) useFrame((state) => api.position.set( (state.pointer.x * viewport.width) / 2, (state.pointer.y * viewport.height) / 2, 0 ) ) return ( ) } ================================================ FILE: apps/web/src/components/hero-simulation.tsx ================================================ 'use client' import { MarchingCube, MarchingCubes, MeshTransmissionMaterial, RenderTexture, Text } from '@react-three/drei' import { Canvas, useFrame } from '@react-three/fiber' import { BallCollider, Physics, RigidBody } from '@react-three/rapier' import { useRef } from 'react' import { suspend } from 'suspend-react' import * as THREE from 'three' const inter = import('@pmndrs/assets/fonts/inter_regular.woff') // https://codesandbox.io/p/sandbox/metaballs-forked-g7xjjq?file=%2Fsrc%2FApp.js function MetaBall({ float = false, strength = 0.5, color, vec = new THREE.Vector3(), ...props }: { float?: boolean strength?: number color?: string vec?: THREE.Vector3 } & Parameters[0]) { const api = useRef(null) useFrame((_state, delta) => { if (float) { delta = Math.min(delta, 0.1) api.current?.applyImpulse( vec //.set(-state.pointer.x, -state.pointer.y, 0) .copy(api.current.translation()) .normalize() .multiplyScalar(delta * -0.2) ) } }) return ( ) } function Pointer({ vec = new THREE.Vector3() }) { const ref = useRef(null) useFrame(({ pointer, viewport }) => { const { width, height } = viewport.getCurrentViewport() vec.set((pointer.x * width) / 2, (pointer.y * height) / 2, 0) ref.current?.setNextKinematicTranslation(vec) }) return ( ) } export function HeroSimulation({ className }: { className?: string }) { return ( MCP {Array.from({ length: 10 }, (_, index) => ( ))} {/* */} ) } ================================================ FILE: apps/web/src/components/loading-indicator/index.tsx ================================================ 'use client' // import { AnimatePresence, motion } from 'motion/react' import dynamic from 'next/dynamic' import { useTheme } from 'next-themes' import { cn } from '@/lib/utils' import loadingDark from './loading-dark.json' import loadingLight from './loading-light.json' import styles from './styles.module.css' const Lottie = dynamic(() => import('react-lottie-player'), { ssr: false }) export function LoadingIndicator({ className }: { className?: string }) { const { resolvedTheme } = useTheme() return ( // // {isLoading ? ( // // // // ) : null} // ) } ================================================ FILE: apps/web/src/components/loading-indicator/loading-dark.json ================================================ { "assets": [ { "id": "comp_1", "layers": [ { "ddd": 0, "ind": 0, "ty": 4, "nm": "Shape Layer 3", "ks": { "o": { "k": [ { "i": { "x": [0.833], "y": [0.833] }, "o": { "x": [0.167], "y": [0.167] }, "n": ["0p833_0p833_0p167_0p167"], "t": 10.4, "s": [100], "e": [0] }, { "t": 25.6 } ] }, "r": { "k": 0 }, "p": { "k": [700, 400, 0] }, "a": { "k": [0, 0, 0] }, "s": { "k": [100, 100, 100] } }, "ao": 0, "hasMask": true, "masksProperties": [ { "inv": true, "mode": "a", "pt": { "k": [ { "i": { "x": 0.833, "y": 0.833 }, "o": { "x": 0.167, "y": 0.167 }, "n": "0p833_0p833_0p167_0p167", "t": 12, "s": [ { "i": [ [134.688, 0], [0, -134.688], [-134.688, 0], [0, 134.688] ], "o": [ [-134.688, 0], [0, 134.688], [134.688, 0], [0, -134.688] ], "v": [ [0, -244.875], [-243.875, -1], [0, 242.875], [243.875, -1] ], "c": true } ], "e": [ { "i": [ [176.731, 0], [0, -176.731], [-176.731, 0], [0, 176.731] ], "o": [ [-176.731, 0], [0, 176.731], [176.731, 0], [0, -176.731] ], "v": [ [0, -321], [-320, -1], [0, 319], [320, -1] ], "c": true } ] }, { "t": 22.4 } ] }, "o": { "k": 100 }, "x": { "k": 0 }, "nm": "Mask 1" } ], "shapes": [ { "ty": "gr", "it": [ { "ind": 0, "ty": "sh", "ks": { "k": { "i": [ [0, 0], [-24.109, -23.866], [-58.097, 2.286], [-4.307, 101.702], [108.95, -0.605], [0, 0], [36.992, -44.122], [100.991, -5.514], [22.177, 25.867], [6.132, 116.1], [-41.842, 62.08], [-30.638, 10.829], [-51.58, -26.549], [0, 0] ], "o": [ [0, 0], [24.109, 23.866], [74.937, -2.949], [5.422, -128.032], [-88.536, 0.492], [0, 0], [-17.89, 21.338], [-138.946, 7.586], [-22.174, -25.864], [-2.384, -45.141], [41.572, -61.679], [77.499, -27.393], [92.496, 47.608], [0, 0] ], "v": [ [116, 52], [197.891, 144.134], [328.097, 197.714], [497.578, 23.032], [311.536, -190.492], [139.357, -79.71], [-73.007, 190.878], [-281.054, 311.414], [-531.052, 222.093], [-620.132, 10.9], [-568.112, -177.098], [-425.186, -291.697], [-199.496, -289.608], [-46, -153] ], "c": false } }, "nm": "Path 1", "mn": "ADBE Vector Shape - Group" }, { "ind": 1, "ty": "sh", "ks": { "k": { "i": [ [-44, -50], [-152, 2], [-63.813, 80.106], [89, 81], [84, 0], [52, -59], [38, -40], [81, 0], [-1.027, 116.034], [-81, 0], [-31, -36], [0, 0] ], "o": [ [44, 50], [105.034, -1.382], [94, -118], [-79.495, -72.35], [-84, 0], [-52, 59], [-36.789, 38.725], [-61.033, 0], [1, -113], [81, 0], [31, 36], [0, 0] ], "v": [ [41, 152], [302, 310], [554, 190], [536, -217], [307, -309], [84, -198], [-176, 130], [-327, 196], [-500, -3], [-330, -192], [-183, -127], [-120, -57] ], "c": false } }, "nm": "Path 2", "mn": "ADBE Vector Shape - Group" }, { "ty": "tm", "s": { "k": [ { "i": { "x": [0.833], "y": [0.833] }, "o": { "x": [0.167], "y": [0.167] }, "n": ["0p833_0p833_0p167_0p167"], "t": 8.8, "s": [30], "e": [0] }, { "t": 22.4 } ], "ix": 1 }, "e": { "k": [ { "i": { "x": [0.833], "y": [0.833] }, "o": { "x": [0.167], "y": [0.167] }, "n": ["0p833_0p833_0p167_0p167"], "t": 8.8, "s": [30], "e": [60] }, { "t": 22.4 } ], "ix": 2 }, "o": { "k": -110, "ix": 3 }, "m": 1, "ix": 3, "nm": "Trim Paths 1", "mn": "ADBE Vector Filter - Trim" }, { "ty": "st", "fillEnabled": true, "c": { "k": [1, 1, 1, 1] }, "o": { "k": 100 }, "w": { "k": 11 }, "lc": 2, "lj": 2, "nm": "Stroke 1", "mn": "ADBE Vector Graphic - Stroke" }, { "ty": "tr", "p": { "k": [0, 0], "ix": 2 }, "a": { "k": [0, 0], "ix": 1 }, "s": { "k": [100, 100], "ix": 3 }, "r": { "k": 0, "ix": 6 }, "o": { "k": 100, "ix": 7 }, "sk": { "k": 0, "ix": 4 }, "sa": { "k": 0, "ix": 5 }, "nm": "Transform" } ], "nm": "Shape 1", "np": 4, "mn": "ADBE Vector Group" } ], "ip": 0, "op": 100, "st": 0, "bm": 0, "sr": 1 }, { "ddd": 0, "ind": 1, "ty": 4, "nm": "Shape Layer 2", "ks": { "o": { "k": 100 }, "r": { "k": 0 }, "p": { "k": [700, 400, 0] }, "a": { "k": [0, 0, 0] }, "s": { "k": [100, 100, 100] } }, "ao": 0, "shapes": [ { "ty": "gr", "it": [ { "ind": 0, "ty": "sh", "ks": { "k": { "i": [ [0, 0], [-24.109, -23.866], [-58.097, 2.286], [-4.307, 101.702], [108.95, -0.605], [0, 0], [36.992, -44.122], [100.991, -5.514], [22.177, 25.867], [6.132, 116.1], [-41.842, 62.08], [-30.638, 10.829], [-51.58, -26.549], [0, 0] ], "o": [ [0, 0], [24.109, 23.866], [74.937, -2.949], [5.422, -128.032], [-88.536, 0.492], [0, 0], [-17.89, 21.338], [-138.946, 7.586], [-22.174, -25.864], [-2.384, -45.141], [41.572, -61.679], [77.499, -27.393], [92.496, 47.608], [0, 0] ], "v": [ [116, 52], [197.891, 144.134], [328.097, 197.714], [497.578, 23.032], [311.536, -190.492], [139.357, -79.71], [-73.007, 190.878], [-281.054, 311.414], [-531.052, 222.093], [-620.132, 10.9], [-568.112, -177.098], [-425.186, -291.697], [-199.496, -289.608], [-46, -153] ], "c": false } }, "nm": "Path 1", "mn": "ADBE Vector Shape - Group" }, { "ind": 1, "ty": "sh", "ks": { "k": { "i": [ [-44, -50], [-152, 2], [-63.813, 80.106], [89, 81], [84, 0], [52, -59], [38, -40], [81, 0], [-1.027, 116.034], [-81, 0], [-31, -36], [0, 0] ], "o": [ [44, 50], [105.034, -1.382], [94, -118], [-79.495, -72.35], [-84, 0], [-52, 59], [-36.789, 38.725], [-61.033, 0], [1, -113], [81, 0], [31, 36], [0, 0] ], "v": [ [41, 152], [302, 310], [554, 190], [536, -217], [307, -309], [84, -198], [-176, 130], [-327, 196], [-500, -3], [-330, -192], [-183, -127], [-120, -57] ], "c": false } }, "nm": "Path 2", "mn": "ADBE Vector Shape - Group" }, { "ty": "tm", "s": { "k": 0, "ix": 1 }, "e": { "k": 26, "ix": 2 }, "o": { "k": [ { "i": { "x": [0.833], "y": [0.833] }, "o": { "x": [0.167], "y": [0.167] }, "n": ["0p833_0p833_0p167_0p167"], "t": 0, "s": [0], "e": [360] }, { "i": { "x": [0.833], "y": [0.833] }, "o": { "x": [0.167], "y": [0.167] }, "n": ["0p833_0p833_0p167_0p167"], "t": 40, "s": [360], "e": [720] }, { "t": 80 } ], "ix": 3 }, "m": 1, "ix": 3, "nm": "Trim Paths 1", "mn": "ADBE Vector Filter - Trim" }, { "ty": "st", "fillEnabled": true, "c": { "k": [1, 1, 1, 1] }, "o": { "k": 100 }, "w": { "k": 11 }, "lc": 2, "lj": 2, "nm": "Stroke 1", "mn": "ADBE Vector Graphic - Stroke" }, { "ty": "tr", "p": { "k": [0, 0], "ix": 2 }, "a": { "k": [0, 0], "ix": 1 }, "s": { "k": [100, 100], "ix": 3 }, "r": { "k": 0, "ix": 6 }, "o": { "k": 100, "ix": 7 }, "sk": { "k": 0, "ix": 4 }, "sa": { "k": 0, "ix": 5 }, "nm": "Transform" } ], "nm": "Shape 1", "np": 4, "mn": "ADBE Vector Group" } ], "ip": 0, "op": 100, "st": 0, "bm": 0, "sr": 1 }, { "ddd": 0, "ind": 2, "ty": 4, "nm": "Shape Layer 1", "ks": { "o": { "k": 100 }, "r": { "k": 0 }, "p": { "k": [700, 400, 0] }, "a": { "k": [0, 0, 0] }, "s": { "k": [102, 102, 100] } }, "ao": 0, "shapes": [ { "ty": "gr", "it": [ { "ind": 0, "ty": "sh", "ks": { "k": { "i": [ [0, 0], [0, 0], [0, 0], [0, 0], [0, 0], [0, 0], [0, 0], [0, 0], [0, 0], [0, 0], [0, 0], [0, 0], [0, 0], [0, 0], [0, 0], [0, 0], [0, 0], [0, 0], [0, 0], [0, 0], [0, 0], [0, 0], [0, 0], [0, 0], [0, 0], [0, 0], [0, 0], [0, 0] ], "o": [ [0, 0], [0, 0], [0, 0], [0, 0], [0, 0], [0, 0], [0, 0], [0, 0], [0, 0], [0, 0], [0, 0], [0, 0], [0, 0], [0, 0], [0, 0], [0, 0], [0, 0], [0, 0], [0, 0], [0, 0], [0, 0], [0, 0], [0, 0], [0, 0], [0, 0], [0, 0], [0, 0], [0, 0] ], "v": [ [41.593, 144.622], [169.506, 119.927], [189.109, 279.448], [324.733, 198.952], [451.572, 261.435], [464.308, 109.882], [591.263, 63.386], [489.394, -41.116], [545.422, -183.975], [401.99, -165.308], [315.929, -299.146], [228.713, -166.646], [75.238, -178.354], [75.296, -7.911], [-76.402, 13.881], [-74.277, 186.236], [-211.124, 169.355], [-300.932, 303.028], [-390.031, 173.297], [-533.818, 195.069], [-486.799, 51.51], [-603.444, -31.631], [-472.297, -92.031], [-477.325, -249.524], [-333.828, -192.011], [-212.105, -287.275], [-181.109, -134.238], [-48.486, -147.583] ], "c": false } }, "nm": "Path 2", "mn": "ADBE Vector Shape - Group" }, { "ty": "tm", "s": { "k": 0, "ix": 1 }, "e": { "k": 22, "ix": 2 }, "o": { "k": [ { "i": { "x": [0.833], "y": [0.833] }, "o": { "x": [0.167], "y": [0.167] }, "n": ["0p833_0p833_0p167_0p167"], "t": 0, "s": [0], "e": [360] }, { "i": { "x": [0.833], "y": [0.833] }, "o": { "x": [0.167], "y": [0.167] }, "n": ["0p833_0p833_0p167_0p167"], "t": 40, "s": [360], "e": [720] }, { "t": 80 } ], "ix": 3 }, "m": 1, "ix": 3, "nm": "Trim Paths 1", "mn": "ADBE Vector Filter - Trim" }, { "ty": "st", "fillEnabled": true, "c": { "k": [1, 1, 1, 1] }, "o": { "k": 100 }, "w": { "k": 10 }, "lc": 2, "lj": 2, "nm": "Stroke 1", "mn": "ADBE Vector Graphic - Stroke" }, { "ty": "tr", "p": { "k": [0, 0], "ix": 2 }, "a": { "k": [0, 0], "ix": 1 }, "s": { "k": [100, 100], "ix": 3 }, "r": { "k": 0, "ix": 6 }, "o": { "k": 100, "ix": 7 }, "sk": { "k": 0, "ix": 4 }, "sa": { "k": 0, "ix": 5 }, "nm": "Transform" } ], "nm": "Shape 1", "np": 4, "mn": "ADBE Vector Group" } ], "ip": 0, "op": 100, "st": 0, "bm": 0, "sr": 1 }, { "ddd": 0, "ind": 3, "ty": 4, "nm": "Shape Layer 5", "ks": { "o": { "k": 18 }, "r": { "k": 0 }, "p": { "k": [700, 400, 0] }, "a": { "k": [0, 0, 0] }, "s": { "k": [100, 100, 100] } }, "ao": 0, "shapes": [ { "ty": "gr", "it": [ { "ind": 0, "ty": "sh", "ks": { "k": { "i": [ [0, 0], [-24.109, -23.866], [-58.097, 2.286], [-4.307, 101.702], [108.95, -0.605], [0, 0], [36.992, -44.122], [100.991, -5.514], [22.177, 25.867], [6.132, 116.1], [-41.842, 62.08], [-30.638, 10.829], [-51.58, -26.549], [0, 0] ], "o": [ [0, 0], [24.109, 23.866], [74.937, -2.949], [5.422, -128.032], [-88.536, 0.492], [0, 0], [-17.89, 21.338], [-138.946, 7.586], [-22.174, -25.864], [-2.384, -45.141], [41.572, -61.679], [77.499, -27.393], [92.496, 47.608], [0, 0] ], "v": [ [116, 52], [197.891, 144.134], [328.097, 197.714], [497.578, 23.032], [311.536, -190.492], [139.357, -79.71], [-73.007, 190.878], [-281.054, 311.414], [-531.052, 222.093], [-620.132, 10.9], [-568.112, -177.098], [-425.186, -291.697], [-199.496, -289.608], [-46, -153] ], "c": false } }, "nm": "Path 1", "mn": "ADBE Vector Shape - Group" }, { "ind": 1, "ty": "sh", "ks": { "k": { "i": [ [-44, -50], [-152, 2], [-63.813, 80.106], [89, 81], [84, 0], [52, -59], [38, -40], [81, 0], [-1.027, 116.034], [-81, 0], [-31, -36], [0, 0] ], "o": [ [44, 50], [105.034, -1.382], [94, -118], [-79.495, -72.35], [-84, 0], [-52, 59], [-36.789, 38.725], [-61.033, 0], [1, -113], [81, 0], [31, 36], [0, 0] ], "v": [ [41, 152], [302, 310], [554, 190], [536, -217], [307, -309], [84, -198], [-176, 130], [-327, 196], [-500, -3], [-330, -192], [-183, -127], [-120, -57] ], "c": false } }, "nm": "Path 2", "mn": "ADBE Vector Shape - Group" }, { "ty": "tm", "s": { "k": 0, "ix": 1 }, "e": { "k": 26, "ix": 2 }, "o": { "k": [ { "i": { "x": [0.833], "y": [0.833] }, "o": { "x": [0.167], "y": [0.167] }, "n": ["0p833_0p833_0p167_0p167"], "t": -33.6, "s": [0], "e": [360] }, { "i": { "x": [0.833], "y": [0.833] }, "o": { "x": [0.167], "y": [0.167] }, "n": ["0p833_0p833_0p167_0p167"], "t": 6.4, "s": [360], "e": [720] }, { "i": { "x": [0.833], "y": [0.833] }, "o": { "x": [0.167], "y": [0.167] }, "n": ["0p833_0p833_0p167_0p167"], "t": 46.4, "s": [720], "e": [1080] }, { "t": 86.4 } ], "ix": 3 }, "m": 1, "ix": 3, "nm": "Trim Paths 1", "mn": "ADBE Vector Filter - Trim" }, { "ty": "st", "fillEnabled": true, "c": { "k": [1, 1, 1, 1] }, "o": { "k": 100 }, "w": { "k": 11 }, "lc": 2, "lj": 2, "nm": "Stroke 1", "mn": "ADBE Vector Graphic - Stroke" }, { "ty": "tr", "p": { "k": [0, 0], "ix": 2 }, "a": { "k": [0, 0], "ix": 1 }, "s": { "k": [100, 100], "ix": 3 }, "r": { "k": 0, "ix": 6 }, "o": { "k": 100, "ix": 7 }, "sk": { "k": 0, "ix": 4 }, "sa": { "k": 0, "ix": 5 }, "nm": "Transform" } ], "nm": "Shape 1", "np": 4, "mn": "ADBE Vector Group" } ], "ip": -33.6, "op": 90.4, "st": -33.6, "bm": 0, "sr": 1 }, { "ddd": 0, "ind": 4, "ty": 4, "nm": "Shape Layer 4", "ks": { "o": { "k": 18 }, "r": { "k": 0 }, "p": { "k": [700, 400, 0] }, "a": { "k": [0, 0, 0] }, "s": { "k": [102, 102, 100] } }, "ao": 0, "shapes": [ { "ty": "gr", "it": [ { "ind": 0, "ty": "sh", "ks": { "k": { "i": [ [0, 0], [0, 0], [0, 0], [0, 0], [0, 0], [0, 0], [0, 0], [0, 0], [0, 0], [0, 0], [0, 0], [0, 0], [0, 0], [0, 0], [0, 0], [0, 0], [0, 0], [0, 0], [0, 0], [0, 0], [0, 0], [0, 0], [0, 0], [0, 0], [0, 0], [0, 0], [0, 0], [0, 0] ], "o": [ [0, 0], [0, 0], [0, 0], [0, 0], [0, 0], [0, 0], [0, 0], [0, 0], [0, 0], [0, 0], [0, 0], [0, 0], [0, 0], [0, 0], [0, 0], [0, 0], [0, 0], [0, 0], [0, 0], [0, 0], [0, 0], [0, 0], [0, 0], [0, 0], [0, 0], [0, 0], [0, 0], [0, 0] ], "v": [ [41.593, 144.622], [169.506, 119.927], [189.109, 279.448], [324.733, 198.952], [451.572, 261.435], [464.308, 109.882], [591.263, 63.386], [489.394, -41.116], [545.422, -183.975], [401.99, -165.308], [315.929, -299.146], [228.713, -166.646], [75.238, -178.354], [75.296, -7.911], [-76.402, 13.881], [-74.277, 186.236], [-211.124, 169.355], [-300.932, 303.028], [-390.031, 173.297], [-533.818, 195.069], [-486.799, 51.51], [-603.444, -31.631], [-472.297, -92.031], [-477.325, -249.524], [-333.828, -192.011], [-212.105, -287.275], [-181.109, -134.238], [-48.486, -147.583] ], "c": false } }, "nm": "Path 2", "mn": "ADBE Vector Shape - Group" }, { "ty": "tm", "s": { "k": 0, "ix": 1 }, "e": { "k": 22, "ix": 2 }, "o": { "k": [ { "i": { "x": [0.833], "y": [0.833] }, "o": { "x": [0.167], "y": [0.167] }, "n": ["0p833_0p833_0p167_0p167"], "t": -33.6, "s": [0], "e": [360] }, { "i": { "x": [0.833], "y": [0.833] }, "o": { "x": [0.167], "y": [0.167] }, "n": ["0p833_0p833_0p167_0p167"], "t": 6.4, "s": [360], "e": [720] }, { "i": { "x": [0.833], "y": [0.833] }, "o": { "x": [0.167], "y": [0.167] }, "n": ["0p833_0p833_0p167_0p167"], "t": 46.4, "s": [720], "e": [1080] }, { "t": 86.4 } ], "ix": 3 }, "m": 1, "ix": 3, "nm": "Trim Paths 1", "mn": "ADBE Vector Filter - Trim" }, { "ty": "st", "fillEnabled": true, "c": { "k": [1, 1, 1, 1] }, "o": { "k": 100 }, "w": { "k": 10 }, "lc": 2, "lj": 2, "nm": "Stroke 1", "mn": "ADBE Vector Graphic - Stroke" }, { "ty": "tr", "p": { "k": [0, 0], "ix": 2 }, "a": { "k": [0, 0], "ix": 1 }, "s": { "k": [100, 100], "ix": 3 }, "r": { "k": 0, "ix": 6 }, "o": { "k": 100, "ix": 7 }, "sk": { "k": 0, "ix": 4 }, "sa": { "k": 0, "ix": 5 }, "nm": "Transform" } ], "nm": "Shape 1", "np": 4, "mn": "ADBE Vector Group" } ], "ip": -33.6, "op": 90.4, "st": -33.6, "bm": 0, "sr": 1 } ] } ], "layers": [ { "ddd": 0, "ind": 0, "ty": 0, "nm": "logo", "td": 1, "refId": "comp_1", "ks": { "o": { "k": 100 }, "r": { "k": 0 }, "p": { "k": [80, 80, 0] }, "a": { "k": [700, 400, 0] }, "s": { "k": [10.286, 10.286, 100] } }, "ao": 0, "w": 1400, "h": 800, "ip": 0, "op": 100, "st": 0, "bm": 0, "sr": 1 }, { "ddd": 0, "ind": 1, "ty": 1, "nm": "Dark Gray Solid 1", "tt": 3, "ks": { "o": { "k": 100 }, "r": { "k": 0 }, "p": { "k": [80, 80, 0] }, "a": { "k": [700, 400, 0] }, "s": { "k": [100, 100, 100] } }, "ao": 0, "sw": 1400, "sh": 800, "sc": "#eeeeee", "ip": 0, "op": 100, "st": 0, "bm": 0, "sr": 1 } ], "v": "4.5.3", "ddd": 0, "ip": 0, "op": 40, "fr": 20, "w": 160, "h": 160 } ================================================ FILE: apps/web/src/components/loading-indicator/loading-light.json ================================================ { "assets": [ { "id": "comp_1", "layers": [ { "ddd": 0, "ind": 0, "ty": 4, "nm": "Shape Layer 3", "ks": { "o": { "k": [ { "i": { "x": [0.833], "y": [0.833] }, "o": { "x": [0.167], "y": [0.167] }, "n": ["0p833_0p833_0p167_0p167"], "t": 10.4, "s": [100], "e": [0] }, { "t": 25.6 } ] }, "r": { "k": 0 }, "p": { "k": [700, 400, 0] }, "a": { "k": [0, 0, 0] }, "s": { "k": [100, 100, 100] } }, "ao": 0, "hasMask": true, "masksProperties": [ { "inv": true, "mode": "a", "pt": { "k": [ { "i": { "x": 0.833, "y": 0.833 }, "o": { "x": 0.167, "y": 0.167 }, "n": "0p833_0p833_0p167_0p167", "t": 12, "s": [ { "i": [ [134.688, 0], [0, -134.688], [-134.688, 0], [0, 134.688] ], "o": [ [-134.688, 0], [0, 134.688], [134.688, 0], [0, -134.688] ], "v": [ [0, -244.875], [-243.875, -1], [0, 242.875], [243.875, -1] ], "c": true } ], "e": [ { "i": [ [176.731, 0], [0, -176.731], [-176.731, 0], [0, 176.731] ], "o": [ [-176.731, 0], [0, 176.731], [176.731, 0], [0, -176.731] ], "v": [ [0, -321], [-320, -1], [0, 319], [320, -1] ], "c": true } ] }, { "t": 22.4 } ] }, "o": { "k": 100 }, "x": { "k": 0 }, "nm": "Mask 1" } ], "shapes": [ { "ty": "gr", "it": [ { "ind": 0, "ty": "sh", "ks": { "k": { "i": [ [0, 0], [-24.109, -23.866], [-58.097, 2.286], [-4.307, 101.702], [108.95, -0.605], [0, 0], [36.992, -44.122], [100.991, -5.514], [22.177, 25.867], [6.132, 116.1], [-41.842, 62.08], [-30.638, 10.829], [-51.58, -26.549], [0, 0] ], "o": [ [0, 0], [24.109, 23.866], [74.937, -2.949], [5.422, -128.032], [-88.536, 0.492], [0, 0], [-17.89, 21.338], [-138.946, 7.586], [-22.174, -25.864], [-2.384, -45.141], [41.572, -61.679], [77.499, -27.393], [92.496, 47.608], [0, 0] ], "v": [ [116, 52], [197.891, 144.134], [328.097, 197.714], [497.578, 23.032], [311.536, -190.492], [139.357, -79.71], [-73.007, 190.878], [-281.054, 311.414], [-531.052, 222.093], [-620.132, 10.9], [-568.112, -177.098], [-425.186, -291.697], [-199.496, -289.608], [-46, -153] ], "c": false } }, "nm": "Path 1", "mn": "ADBE Vector Shape - Group" }, { "ind": 1, "ty": "sh", "ks": { "k": { "i": [ [-44, -50], [-152, 2], [-63.813, 80.106], [89, 81], [84, 0], [52, -59], [38, -40], [81, 0], [-1.027, 116.034], [-81, 0], [-31, -36], [0, 0] ], "o": [ [44, 50], [105.034, -1.382], [94, -118], [-79.495, -72.35], [-84, 0], [-52, 59], [-36.789, 38.725], [-61.033, 0], [1, -113], [81, 0], [31, 36], [0, 0] ], "v": [ [41, 152], [302, 310], [554, 190], [536, -217], [307, -309], [84, -198], [-176, 130], [-327, 196], [-500, -3], [-330, -192], [-183, -127], [-120, -57] ], "c": false } }, "nm": "Path 2", "mn": "ADBE Vector Shape - Group" }, { "ty": "tm", "s": { "k": [ { "i": { "x": [0.833], "y": [0.833] }, "o": { "x": [0.167], "y": [0.167] }, "n": ["0p833_0p833_0p167_0p167"], "t": 8.8, "s": [30], "e": [0] }, { "t": 22.4 } ], "ix": 1 }, "e": { "k": [ { "i": { "x": [0.833], "y": [0.833] }, "o": { "x": [0.167], "y": [0.167] }, "n": ["0p833_0p833_0p167_0p167"], "t": 8.8, "s": [30], "e": [60] }, { "t": 22.4 } ], "ix": 2 }, "o": { "k": -110, "ix": 3 }, "m": 1, "ix": 3, "nm": "Trim Paths 1", "mn": "ADBE Vector Filter - Trim" }, { "ty": "st", "fillEnabled": true, "c": { "k": [1, 1, 1, 1] }, "o": { "k": 100 }, "w": { "k": 11 }, "lc": 2, "lj": 2, "nm": "Stroke 1", "mn": "ADBE Vector Graphic - Stroke" }, { "ty": "tr", "p": { "k": [0, 0], "ix": 2 }, "a": { "k": [0, 0], "ix": 1 }, "s": { "k": [100, 100], "ix": 3 }, "r": { "k": 0, "ix": 6 }, "o": { "k": 100, "ix": 7 }, "sk": { "k": 0, "ix": 4 }, "sa": { "k": 0, "ix": 5 }, "nm": "Transform" } ], "nm": "Shape 1", "np": 4, "mn": "ADBE Vector Group" } ], "ip": 0, "op": 100, "st": 0, "bm": 0, "sr": 1 }, { "ddd": 0, "ind": 1, "ty": 4, "nm": "Shape Layer 2", "ks": { "o": { "k": 100 }, "r": { "k": 0 }, "p": { "k": [700, 400, 0] }, "a": { "k": [0, 0, 0] }, "s": { "k": [100, 100, 100] } }, "ao": 0, "shapes": [ { "ty": "gr", "it": [ { "ind": 0, "ty": "sh", "ks": { "k": { "i": [ [0, 0], [-24.109, -23.866], [-58.097, 2.286], [-4.307, 101.702], [108.95, -0.605], [0, 0], [36.992, -44.122], [100.991, -5.514], [22.177, 25.867], [6.132, 116.1], [-41.842, 62.08], [-30.638, 10.829], [-51.58, -26.549], [0, 0] ], "o": [ [0, 0], [24.109, 23.866], [74.937, -2.949], [5.422, -128.032], [-88.536, 0.492], [0, 0], [-17.89, 21.338], [-138.946, 7.586], [-22.174, -25.864], [-2.384, -45.141], [41.572, -61.679], [77.499, -27.393], [92.496, 47.608], [0, 0] ], "v": [ [116, 52], [197.891, 144.134], [328.097, 197.714], [497.578, 23.032], [311.536, -190.492], [139.357, -79.71], [-73.007, 190.878], [-281.054, 311.414], [-531.052, 222.093], [-620.132, 10.9], [-568.112, -177.098], [-425.186, -291.697], [-199.496, -289.608], [-46, -153] ], "c": false } }, "nm": "Path 1", "mn": "ADBE Vector Shape - Group" }, { "ind": 1, "ty": "sh", "ks": { "k": { "i": [ [-44, -50], [-152, 2], [-63.813, 80.106], [89, 81], [84, 0], [52, -59], [38, -40], [81, 0], [-1.027, 116.034], [-81, 0], [-31, -36], [0, 0] ], "o": [ [44, 50], [105.034, -1.382], [94, -118], [-79.495, -72.35], [-84, 0], [-52, 59], [-36.789, 38.725], [-61.033, 0], [1, -113], [81, 0], [31, 36], [0, 0] ], "v": [ [41, 152], [302, 310], [554, 190], [536, -217], [307, -309], [84, -198], [-176, 130], [-327, 196], [-500, -3], [-330, -192], [-183, -127], [-120, -57] ], "c": false } }, "nm": "Path 2", "mn": "ADBE Vector Shape - Group" }, { "ty": "tm", "s": { "k": 0, "ix": 1 }, "e": { "k": 26, "ix": 2 }, "o": { "k": [ { "i": { "x": [0.833], "y": [0.833] }, "o": { "x": [0.167], "y": [0.167] }, "n": ["0p833_0p833_0p167_0p167"], "t": 0, "s": [0], "e": [360] }, { "i": { "x": [0.833], "y": [0.833] }, "o": { "x": [0.167], "y": [0.167] }, "n": ["0p833_0p833_0p167_0p167"], "t": 40, "s": [360], "e": [720] }, { "t": 80 } ], "ix": 3 }, "m": 1, "ix": 3, "nm": "Trim Paths 1", "mn": "ADBE Vector Filter - Trim" }, { "ty": "st", "fillEnabled": true, "c": { "k": [1, 1, 1, 1] }, "o": { "k": 100 }, "w": { "k": 11 }, "lc": 2, "lj": 2, "nm": "Stroke 1", "mn": "ADBE Vector Graphic - Stroke" }, { "ty": "tr", "p": { "k": [0, 0], "ix": 2 }, "a": { "k": [0, 0], "ix": 1 }, "s": { "k": [100, 100], "ix": 3 }, "r": { "k": 0, "ix": 6 }, "o": { "k": 100, "ix": 7 }, "sk": { "k": 0, "ix": 4 }, "sa": { "k": 0, "ix": 5 }, "nm": "Transform" } ], "nm": "Shape 1", "np": 4, "mn": "ADBE Vector Group" } ], "ip": 0, "op": 100, "st": 0, "bm": 0, "sr": 1 }, { "ddd": 0, "ind": 2, "ty": 4, "nm": "Shape Layer 1", "ks": { "o": { "k": 100 }, "r": { "k": 0 }, "p": { "k": [700, 400, 0] }, "a": { "k": [0, 0, 0] }, "s": { "k": [102, 102, 100] } }, "ao": 0, "shapes": [ { "ty": "gr", "it": [ { "ind": 0, "ty": "sh", "ks": { "k": { "i": [ [0, 0], [0, 0], [0, 0], [0, 0], [0, 0], [0, 0], [0, 0], [0, 0], [0, 0], [0, 0], [0, 0], [0, 0], [0, 0], [0, 0], [0, 0], [0, 0], [0, 0], [0, 0], [0, 0], [0, 0], [0, 0], [0, 0], [0, 0], [0, 0], [0, 0], [0, 0], [0, 0], [0, 0] ], "o": [ [0, 0], [0, 0], [0, 0], [0, 0], [0, 0], [0, 0], [0, 0], [0, 0], [0, 0], [0, 0], [0, 0], [0, 0], [0, 0], [0, 0], [0, 0], [0, 0], [0, 0], [0, 0], [0, 0], [0, 0], [0, 0], [0, 0], [0, 0], [0, 0], [0, 0], [0, 0], [0, 0], [0, 0] ], "v": [ [41.593, 144.622], [169.506, 119.927], [189.109, 279.448], [324.733, 198.952], [451.572, 261.435], [464.308, 109.882], [591.263, 63.386], [489.394, -41.116], [545.422, -183.975], [401.99, -165.308], [315.929, -299.146], [228.713, -166.646], [75.238, -178.354], [75.296, -7.911], [-76.402, 13.881], [-74.277, 186.236], [-211.124, 169.355], [-300.932, 303.028], [-390.031, 173.297], [-533.818, 195.069], [-486.799, 51.51], [-603.444, -31.631], [-472.297, -92.031], [-477.325, -249.524], [-333.828, -192.011], [-212.105, -287.275], [-181.109, -134.238], [-48.486, -147.583] ], "c": false } }, "nm": "Path 2", "mn": "ADBE Vector Shape - Group" }, { "ty": "tm", "s": { "k": 0, "ix": 1 }, "e": { "k": 22, "ix": 2 }, "o": { "k": [ { "i": { "x": [0.833], "y": [0.833] }, "o": { "x": [0.167], "y": [0.167] }, "n": ["0p833_0p833_0p167_0p167"], "t": 0, "s": [0], "e": [360] }, { "i": { "x": [0.833], "y": [0.833] }, "o": { "x": [0.167], "y": [0.167] }, "n": ["0p833_0p833_0p167_0p167"], "t": 40, "s": [360], "e": [720] }, { "t": 80 } ], "ix": 3 }, "m": 1, "ix": 3, "nm": "Trim Paths 1", "mn": "ADBE Vector Filter - Trim" }, { "ty": "st", "fillEnabled": true, "c": { "k": [1, 1, 1, 1] }, "o": { "k": 100 }, "w": { "k": 10 }, "lc": 2, "lj": 2, "nm": "Stroke 1", "mn": "ADBE Vector Graphic - Stroke" }, { "ty": "tr", "p": { "k": [0, 0], "ix": 2 }, "a": { "k": [0, 0], "ix": 1 }, "s": { "k": [100, 100], "ix": 3 }, "r": { "k": 0, "ix": 6 }, "o": { "k": 100, "ix": 7 }, "sk": { "k": 0, "ix": 4 }, "sa": { "k": 0, "ix": 5 }, "nm": "Transform" } ], "nm": "Shape 1", "np": 4, "mn": "ADBE Vector Group" } ], "ip": 0, "op": 100, "st": 0, "bm": 0, "sr": 1 }, { "ddd": 0, "ind": 3, "ty": 4, "nm": "Shape Layer 5", "ks": { "o": { "k": 18 }, "r": { "k": 0 }, "p": { "k": [700, 400, 0] }, "a": { "k": [0, 0, 0] }, "s": { "k": [100, 100, 100] } }, "ao": 0, "shapes": [ { "ty": "gr", "it": [ { "ind": 0, "ty": "sh", "ks": { "k": { "i": [ [0, 0], [-24.109, -23.866], [-58.097, 2.286], [-4.307, 101.702], [108.95, -0.605], [0, 0], [36.992, -44.122], [100.991, -5.514], [22.177, 25.867], [6.132, 116.1], [-41.842, 62.08], [-30.638, 10.829], [-51.58, -26.549], [0, 0] ], "o": [ [0, 0], [24.109, 23.866], [74.937, -2.949], [5.422, -128.032], [-88.536, 0.492], [0, 0], [-17.89, 21.338], [-138.946, 7.586], [-22.174, -25.864], [-2.384, -45.141], [41.572, -61.679], [77.499, -27.393], [92.496, 47.608], [0, 0] ], "v": [ [116, 52], [197.891, 144.134], [328.097, 197.714], [497.578, 23.032], [311.536, -190.492], [139.357, -79.71], [-73.007, 190.878], [-281.054, 311.414], [-531.052, 222.093], [-620.132, 10.9], [-568.112, -177.098], [-425.186, -291.697], [-199.496, -289.608], [-46, -153] ], "c": false } }, "nm": "Path 1", "mn": "ADBE Vector Shape - Group" }, { "ind": 1, "ty": "sh", "ks": { "k": { "i": [ [-44, -50], [-152, 2], [-63.813, 80.106], [89, 81], [84, 0], [52, -59], [38, -40], [81, 0], [-1.027, 116.034], [-81, 0], [-31, -36], [0, 0] ], "o": [ [44, 50], [105.034, -1.382], [94, -118], [-79.495, -72.35], [-84, 0], [-52, 59], [-36.789, 38.725], [-61.033, 0], [1, -113], [81, 0], [31, 36], [0, 0] ], "v": [ [41, 152], [302, 310], [554, 190], [536, -217], [307, -309], [84, -198], [-176, 130], [-327, 196], [-500, -3], [-330, -192], [-183, -127], [-120, -57] ], "c": false } }, "nm": "Path 2", "mn": "ADBE Vector Shape - Group" }, { "ty": "tm", "s": { "k": 0, "ix": 1 }, "e": { "k": 26, "ix": 2 }, "o": { "k": [ { "i": { "x": [0.833], "y": [0.833] }, "o": { "x": [0.167], "y": [0.167] }, "n": ["0p833_0p833_0p167_0p167"], "t": -33.6, "s": [0], "e": [360] }, { "i": { "x": [0.833], "y": [0.833] }, "o": { "x": [0.167], "y": [0.167] }, "n": ["0p833_0p833_0p167_0p167"], "t": 6.4, "s": [360], "e": [720] }, { "i": { "x": [0.833], "y": [0.833] }, "o": { "x": [0.167], "y": [0.167] }, "n": ["0p833_0p833_0p167_0p167"], "t": 46.4, "s": [720], "e": [1080] }, { "t": 86.4 } ], "ix": 3 }, "m": 1, "ix": 3, "nm": "Trim Paths 1", "mn": "ADBE Vector Filter - Trim" }, { "ty": "st", "fillEnabled": true, "c": { "k": [1, 1, 1, 1] }, "o": { "k": 100 }, "w": { "k": 11 }, "lc": 2, "lj": 2, "nm": "Stroke 1", "mn": "ADBE Vector Graphic - Stroke" }, { "ty": "tr", "p": { "k": [0, 0], "ix": 2 }, "a": { "k": [0, 0], "ix": 1 }, "s": { "k": [100, 100], "ix": 3 }, "r": { "k": 0, "ix": 6 }, "o": { "k": 100, "ix": 7 }, "sk": { "k": 0, "ix": 4 }, "sa": { "k": 0, "ix": 5 }, "nm": "Transform" } ], "nm": "Shape 1", "np": 4, "mn": "ADBE Vector Group" } ], "ip": -33.6, "op": 90.4, "st": -33.6, "bm": 0, "sr": 1 }, { "ddd": 0, "ind": 4, "ty": 4, "nm": "Shape Layer 4", "ks": { "o": { "k": 18 }, "r": { "k": 0 }, "p": { "k": [700, 400, 0] }, "a": { "k": [0, 0, 0] }, "s": { "k": [102, 102, 100] } }, "ao": 0, "shapes": [ { "ty": "gr", "it": [ { "ind": 0, "ty": "sh", "ks": { "k": { "i": [ [0, 0], [0, 0], [0, 0], [0, 0], [0, 0], [0, 0], [0, 0], [0, 0], [0, 0], [0, 0], [0, 0], [0, 0], [0, 0], [0, 0], [0, 0], [0, 0], [0, 0], [0, 0], [0, 0], [0, 0], [0, 0], [0, 0], [0, 0], [0, 0], [0, 0], [0, 0], [0, 0], [0, 0] ], "o": [ [0, 0], [0, 0], [0, 0], [0, 0], [0, 0], [0, 0], [0, 0], [0, 0], [0, 0], [0, 0], [0, 0], [0, 0], [0, 0], [0, 0], [0, 0], [0, 0], [0, 0], [0, 0], [0, 0], [0, 0], [0, 0], [0, 0], [0, 0], [0, 0], [0, 0], [0, 0], [0, 0], [0, 0] ], "v": [ [41.593, 144.622], [169.506, 119.927], [189.109, 279.448], [324.733, 198.952], [451.572, 261.435], [464.308, 109.882], [591.263, 63.386], [489.394, -41.116], [545.422, -183.975], [401.99, -165.308], [315.929, -299.146], [228.713, -166.646], [75.238, -178.354], [75.296, -7.911], [-76.402, 13.881], [-74.277, 186.236], [-211.124, 169.355], [-300.932, 303.028], [-390.031, 173.297], [-533.818, 195.069], [-486.799, 51.51], [-603.444, -31.631], [-472.297, -92.031], [-477.325, -249.524], [-333.828, -192.011], [-212.105, -287.275], [-181.109, -134.238], [-48.486, -147.583] ], "c": false } }, "nm": "Path 2", "mn": "ADBE Vector Shape - Group" }, { "ty": "tm", "s": { "k": 0, "ix": 1 }, "e": { "k": 22, "ix": 2 }, "o": { "k": [ { "i": { "x": [0.833], "y": [0.833] }, "o": { "x": [0.167], "y": [0.167] }, "n": ["0p833_0p833_0p167_0p167"], "t": -33.6, "s": [0], "e": [360] }, { "i": { "x": [0.833], "y": [0.833] }, "o": { "x": [0.167], "y": [0.167] }, "n": ["0p833_0p833_0p167_0p167"], "t": 6.4, "s": [360], "e": [720] }, { "i": { "x": [0.833], "y": [0.833] }, "o": { "x": [0.167], "y": [0.167] }, "n": ["0p833_0p833_0p167_0p167"], "t": 46.4, "s": [720], "e": [1080] }, { "t": 86.4 } ], "ix": 3 }, "m": 1, "ix": 3, "nm": "Trim Paths 1", "mn": "ADBE Vector Filter - Trim" }, { "ty": "st", "fillEnabled": true, "c": { "k": [1, 1, 1, 1] }, "o": { "k": 100 }, "w": { "k": 10 }, "lc": 2, "lj": 2, "nm": "Stroke 1", "mn": "ADBE Vector Graphic - Stroke" }, { "ty": "tr", "p": { "k": [0, 0], "ix": 2 }, "a": { "k": [0, 0], "ix": 1 }, "s": { "k": [100, 100], "ix": 3 }, "r": { "k": 0, "ix": 6 }, "o": { "k": 100, "ix": 7 }, "sk": { "k": 0, "ix": 4 }, "sa": { "k": 0, "ix": 5 }, "nm": "Transform" } ], "nm": "Shape 1", "np": 4, "mn": "ADBE Vector Group" } ], "ip": -33.6, "op": 90.4, "st": -33.6, "bm": 0, "sr": 1 } ] } ], "layers": [ { "ddd": 0, "ind": 0, "ty": 0, "nm": "logo", "td": 1, "refId": "comp_1", "ks": { "o": { "k": 100 }, "r": { "k": 0 }, "p": { "k": [80, 80, 0] }, "a": { "k": [700, 400, 0] }, "s": { "k": [10.286, 10.286, 100] } }, "ao": 0, "w": 1400, "h": 800, "ip": 0, "op": 100, "st": 0, "bm": 0, "sr": 1 }, { "ddd": 0, "ind": 1, "ty": 1, "nm": "Dark Gray Solid 1", "tt": 3, "ks": { "o": { "k": 100 }, "r": { "k": 0 }, "p": { "k": [80, 80, 0] }, "a": { "k": [700, 400, 0] }, "s": { "k": [100, 100, 100] } }, "ao": 0, "sw": 1400, "sh": 800, "sc": "#232323", "ip": 0, "op": 100, "st": 0, "bm": 0, "sr": 1 } ], "v": "4.5.3", "ddd": 0, "ip": 0, "op": 40, "fr": 20, "w": 160, "h": 160 } ================================================ FILE: apps/web/src/components/loading-indicator/styles.module.css ================================================ .loading { position: relative; pointer-events: none; display: flex; flex-direction: column; justify-content: center; align-items: center; margin: 0 auto; } .fill { top: 0; left: 0; right: 0; bottom: 0; } .loadingAnimation { pointer-events: none; width: 200px; height: 200px; max-width: 50vw; max-height: 50vh; } ================================================ FILE: apps/web/src/components/markdown/index.tsx ================================================ import { cn } from '@/lib/utils' import styles from './styles.module.css' export function Markdown({ children, className }: { className?: string children: React.ReactNode }) { return (
{children}
) } ================================================ FILE: apps/web/src/components/markdown/ssr-markdown.tsx ================================================ 'use client' import { markdownToHtml } from '@fisch0920/markdown-to-html' import { useAsync } from 'react-use' import { cn } from '@/lib/utils' import { LoadingIndicator } from '../loading-indicator' import styles from './styles.module.css' // TODO: figure out how to make this server-only to not bloat the client-side bundle export function SSRMarkdown({ className, markdown }: { className?: string markdown: string }) { const { value: html, loading } = useAsync( async () => markdownToHtml(markdown), [markdown] ) if (loading) { return (
) } return (
) } ================================================ FILE: apps/web/src/components/markdown/styles.module.css ================================================ .markdown { width: 100%; max-width: 100%; line-height: 1.6; display: flex; flex-direction: column; align-items: center; } .markdown > * { margin-bottom: 1em; width: 100%; max-width: 830px; } .markdown > :last-child { margin-bottom: 0; } .markdown > *:first-child { margin-top: 0 !important; } .markdown img { display: inline-block; max-width: 100%; margin: 0; } .markdown a:not(:has(img)) { display: inline-block; text-decoration: none; font-weight: inherit; line-height: 1.3; position: relative; transition: unset; opacity: 1; color: unset; padding-bottom: 0.1rem; border-color: var(--border); border-bottom-width: 0.135rem; background: transparent; background-origin: border-box; background-repeat: no-repeat; background-position: 50% 100%; background-size: 0 0.135rem; } .markdown a:hover:not(:has(img)), .markdown a:focus:not(:has(img)) { color: var(--tw-prose-links); text-decoration: none; border-bottom-color: transparent; background-image: linear-gradient(90.68deg, #b439df 0.26%, #e5337e 102.37%); background-repeat: no-repeat; background-position: 0 100%; background-size: 100% 0.135rem; transition-property: background-position, background-size; transition-duration: 300ms; } .markdown :where(ul):not(:where([class~='not-prose'], [class~='not-prose'] *)) { margin-top: 1em; } .markdown p + ul, .markdown p + ol, .markdown h1 + ul, .markdown h2 + ul, .markdown h3 + ul, .markdown h4 + ul, .markdown h5 + ul, .markdown h6 + ul, .markdown h1 + ol, .markdown h2 + ol, .markdown h3 + ol, .markdown h4 + ol, .markdown h5 + ol, .markdown h6 + ol { margin-top: 0; } .markdown p { margin-top: 0; margin-bottom: 1em; } .markdown li a { margin: 0; } .markdown li:first-child { margin-top: 0; } .markdown li:last-child { margin-bottom: 0; } .markdown h1, .markdown h2, .markdown h3, .markdown h4, .markdown h5, .markdown h6 { margin-top: 1.5em; margin-bottom: 0.5em; font-weight: 600; line-height: 1.25; } .markdown h1 { font-size: 2em; } .markdown h2 { font-size: 1.5em; } .markdown h3 { font-size: 1.25em; } .markdown h4 { font-size: 1em; } .markdown h5 { font-size: 1em; font-weight: 700; } .markdown h6 { font-size: 1em; font-weight: 500; } @media (max-width: 800px) { .markdown img { display: block; text-align: center; margin: 0 auto; } } ================================================ FILE: apps/web/src/components/mcp-gateway-features.tsx ================================================ 'use client' import { ChartNoAxesCombinedIcon, CheckCheckIcon, CreditCardIcon, DatabaseZapIcon, HistoryIcon, KeyRoundIcon, ShieldCheckIcon, TextSelectIcon, UserIcon } from 'lucide-react' import { Feature, type FeatureData } from './feature' const docsPublishingUrl = 'https://docs.agentic.so/publishing' const mcpGatewayFeatures: FeatureData[] = [ { name: 'Auth', description: ( <> Ship to production fast with Agentic's free, hosted authentication. Email & password, OAuth, GitHub, Google, Twitter, etc – if your origin API requires OAuth credentials, Agentic likely already supports it, and if not, we'd be happy to add it. ), icon: UserIcon, href: `${docsPublishingUrl}/config/auth`, pattern: { y: 16, squares: [ [0, 1], [1, 3] ] } }, { name: 'Stripe Billing', description: ( <> Charge for your MCP products with a flexible, declarative pricing model built on top of Stripe. Agentic supports almost any combination of fixed and usage-based billing models, both at the MCP level, at the tool-call level, and at the custom metric level (e.g., tokens, image transformations, etc). ), icon: CreditCardIcon, href: `${docsPublishingUrl}/config/pricing`, pattern: { y: -6, squares: [ [-1, 2], [1, 3] ] } }, { name: 'Support both MCP and HTTP', description: ( <> All Agentic tools are exposed as both{' '} MCP servers as well as simple{' '} HTTP APIs. MCP is important for interop and future-proofing, whereas simple HTTP POST requests make tools easy to debug and simplifies usage with LLM tool calling. ), icon: CheckCheckIcon, pattern: { y: 32, squares: [ [0, 2], [1, 4] ] } }, { name: 'API Keys', description: ( <> When a customer subscribes to your product, they're given a unique API key. MCP URLs are appended with this API key to correlate usage with their subscription. Customer HTTP tool calls use the same API key as a standard HTTP Authorization header. ), icon: KeyRoundIcon, pattern: { y: 22, squares: [[0, 1]] } }, { name: 'Rate-Limiting', description: ( <> Agentic durable rate-limiting is built on top of Cloudflare's global infrastructure. Customize the default rate-limits, change them based on a customer's pricing plan, or create custom tool-specific overrides. REST assured that your origin API will be safe behind Agentic's MCP gateway. ), icon: ShieldCheckIcon, href: `${docsPublishingUrl}/config/rate-limits`, pattern: { y: 2, squares: [ [-2, 3], [1, 4] ] } }, { name: 'Caching', description: ( <> Opt-in to caching with familiar cache-control and{' '} stale-while-revalidate options. MCP tool calls include caching information in their _meta fields, providing parity with standard HTTP headers. Agentic uses Cloudflare's global edge cache for caching, which guarantees unmatched global performance. ), icon: DatabaseZapIcon, href: `${docsPublishingUrl}/config/caching`, pattern: { y: 8, squares: [ [0, 2], [-1, 1] ] } }, { name: 'Analytics', description: ( <> Agentic tracks all tool calls for usage-based billing and analytics at a fine-grained level, so you can drill in and deeply understand how your customers are using your product. ), icon: ChartNoAxesCombinedIcon, pattern: { y: -2, squares: [[-2, 2]] } }, { name: 'Versioning & Instant Rollbacks', description: ( <> Agentic uses immutable deployments, so every time you make a change to your product, a unique preview deployment is created. This enables instant rollbacks if there are problems with a deployment. Publishing uses semver (semantic versioning), so your customers can choose how to handle breaking changes. ), icon: HistoryIcon, pattern: { y: 26, squares: [ [2, 4], [-2, 3] ] } }, { name: "That's just the start", description: ( <>Check out our docs for more details on Agentic's MCP gateway. ), href: `${docsPublishingUrl}/quickstart`, icon: TextSelectIcon, pattern: { y: 13, squares: [ [0, 2], [-1, 4] ] } } ] export function MCPGatewayFeatures() { return (
{mcpGatewayFeatures.map((feature) => ( ))}
) } ================================================ FILE: apps/web/src/components/mcp-marketplace-features.tsx ================================================ 'use client' import { CreditCardIcon, FileJsonIcon, HistoryIcon, ShieldCheckIcon, StarIcon, ZapIcon } from 'lucide-react' import { Feature, type FeatureData } from './feature' const mcpMarketplaceFeatures: FeatureData[] = [ { name: 'Highly Curated', description: ( <> All Agentic tools have been hand-crafted specifically for LLM tool use. {' '} We call this Agentic UX, and it's at the heart of why Agentic tools work better for LLM & MCP use cases than legacy APIs. ), icon: StarIcon, pattern: { y: 16, squares: [ [0, 1], [1, 3] ] } }, { name: 'Production-Ready MCP Support', description: ( <> Forget random GitHub repos and gluing local MCP servers together. Agentic tools are all battle-tested in production and come with real SLAs. ), icon: ShieldCheckIcon, pattern: { y: 22, squares: [[0, 1]] } }, { name: 'World-Class TypeScript DX', description: ( <> Agentic is written in TypeScript and strives for a Vercel-like DX. One-line tool integrations for all of the popular TS LLM SDKs ( Vercel AI SDK,{' '} OpenAI,{' '} LangChain, etc). ), icon: FileJsonIcon, pattern: { y: 8, squares: [ [0, 2], [-1, 1] ] } }, { name: 'Stripe Billing', description: ( <> Agentic uses Stripe for billing, and most tools are{' '} usage-based, so you'll only pay for what you (and your agents) actually use. ), icon: CreditCardIcon, pattern: { y: -6, squares: [ [-1, 2], [1, 3] ] } }, { name: 'Blazing Fast MCP Gateway', description: ( <> Agentic's MCP gateway is powered by{' '} Cloudflare's global edge network. Tools come with customizable caching and rate-limits, so you can REST assured that your agents will always have a fast and reliable experience. ), icon: ZapIcon, pattern: { y: 32, squares: [ [0, 2], [1, 4] ] } }, { name: 'Semantic Versioning', description: ( <> All Agentic tools are versioned using{' '} semver, so you can choose how to handle breaking changes. ), icon: HistoryIcon, pattern: { y: 26, squares: [ [2, 4], [-2, 3] ] } } ] export function MCPMarketplaceFeatures() { return (
{mcpMarketplaceFeatures.map((feature) => ( ))}
) } ================================================ FILE: apps/web/src/components/page-container.tsx ================================================ import { cn } from '@/lib/utils' export function PageContainer({ background = true, compact = false, className, children }: { background?: boolean compact?: boolean className?: string children: React.ReactNode }) { return ( <> {background && (
)}
{children}
) } ================================================ FILE: apps/web/src/components/posthog-provider.tsx ================================================ 'use client' import { usePathname, useSearchParams } from 'next/navigation' import { posthog } from 'posthog-js' import { PostHogProvider as PHProvider, usePostHog } from 'posthog-js/react' import { Suspense, useEffect, useState } from 'react' import { posthogHost, posthogKey } from '@/lib/config' export function PostHogProvider({ children }: { children: React.ReactNode }) { useEffect(() => { if (posthogKey) { posthog.init(posthogKey, { api_host: posthogHost, person_profiles: 'identified_only', // or 'always' to create profiles for anonymous users as well capture_pageview: false // Disable automatic pageview capture, as we capture manually }) } }, []) if (!posthogKey) { return children } return ( {children} ) } function PostHogPageView() { const pathname = usePathname() const searchParams = useSearchParams() const posthog = usePostHog() const [prevPathname, setPrevPathname] = useState(null) // Track pageviews useEffect(() => { if (pathname && posthog) { let url = globalThis.window.origin + pathname if (searchParams.toString()) { url = url + '?' + searchParams.toString() } if (prevPathname && prevPathname !== pathname) { posthog.capture('$pageleave', { $pathname: prevPathname }) } posthog.capture('$pageview', { $current_url: url }) setPrevPathname(pathname) } }, [pathname, prevPathname, searchParams, posthog]) return null } // Wrap PostHogPageView in Suspense to avoid the useSearchParams usage above // from de-opting the whole app into client-side rendering // See: https://nextjs.org/docs/messages/deopted-into-client-rendering function SuspendedPostHogPageView() { return ( ) } ================================================ FILE: apps/web/src/components/project-pricing-plans/index.tsx ================================================ import { type Consumer, getPricingPlansByInterval, type PricingInterval, type Project } from '@agentic/platform-types' import { useLocalStorage } from 'react-use' import { ProjectPricingPlan } from './project-pricing-plan' export function ProjectPricingPlans({ project, consumer, isLoadingStripeCheckoutForPlan, onSubscribe }: { project: Project consumer?: Consumer isLoadingStripeCheckoutForPlan: string | null onSubscribe: (planSlug: string) => void className?: string }) { const { defaultPricingInterval } = project // TODO const [pricingInterval, _setPricingInterval] = useLocalStorage( `pricing-interval-${project.identifier}`, defaultPricingInterval ) const deployment = project.lastPublishedDeployment if (!deployment || !pricingInterval) { return null } // const numPricingIntervals = deployment.pricingIntervals.length // const hasSinglePricingInterval = numPricingIntervals === 1 const { pricingPlans } = deployment const pricingPlansByInterval = Object.fromEntries( deployment.pricingIntervals.map((pricingInterval) => [ pricingInterval, getPricingPlansByInterval({ pricingInterval, pricingPlans }) ]) ) const currentPricingIntervalPlans = pricingPlansByInterval[pricingInterval] ?? [] // TODO: add support for different pricing intervals and switching between them const numPricingPlans = currentPricingIntervalPlans.length || 1 return (
{/* {!hasSinglePricingInterval && ()} */}
{deployment.pricingPlans.map((plan) => ( ))}
) } ================================================ FILE: apps/web/src/components/project-pricing-plans/project-pricing-plan.tsx ================================================ import type { Consumer, // PricingInterval, PricingPlan, Project } from '@agentic/platform-types' import humanNumber from 'human-number' import { CornerDownRightIcon, Loader2Icon, PlusIcon, ShieldCheckIcon, ShieldMinusIcon } from 'lucide-react' import Link from 'next/link' import plur from 'plur' import { Button } from '@/components/ui/button' import { getRateLimitIntervalLabel, pricingAmountToFixedString } from '@/lib/utils' // const intervalToLabelMap: Record = { // day: 'daily', // week: 'weekly', // month: 'monthly', // year: 'yearly' // } export function ProjectPricingPlan({ project, plan, consumer, isLoadingStripeCheckoutForPlan, onSubscribe }: { project: Project plan: PricingPlan consumer?: Consumer isLoadingStripeCheckoutForPlan: string | null onSubscribe: (planSlug: string) => void className?: string }) { const { defaultPricingInterval } = project const { lineItems } = plan const interval = plan.interval ?? defaultPricingInterval // const intervalLabel = intervalToLabelMap[interval] const baseLineItem = lineItems.find((lineItem) => lineItem.slug === 'base') const requestsLineItem = lineItems.find( (lineItem) => lineItem.slug === 'requests' ) const isFreePlan = plan.slug === 'free' const deployment = project.lastPublishedDeployment const requestsRateLimit = plan.rateLimit ?? deployment?.defaultRateLimit // TODO: support custom line-items // const customLineItems = lineItems.find( // (lineItem) => lineItem.slug !== 'base' && lineItem.slug !== 'requests' // ) // TODO: support defaultAggregation // TODO: support trialPeriodDays // TODO: highlight if any tools are disabled on this pricing plan return (

{plan.name} Plan

{plan.description &&

{plan.description}

}
$ {baseLineItem ? pricingAmountToFixedString(baseLineItem.amount) : 0}
/ {interval}
{requestsLineItem && !isFreePlan && (

Requests:

{requestsLineItem.billingScheme === 'per_unit' ? (
${pricingAmountToFixedString(requestsLineItem.unitAmount)}
/ per{' '} {requestsLineItem.transformQuantity ? `${requestsLineItem.transformQuantity.divideBy} ${plur('request', requestsLineItem.transformQuantity.divideBy)}` : 'request'}
) : requestsLineItem.billingScheme === 'tiered' ? (
{requestsLineItem.tiers?.map((tier, index) => { const isFirst = index === 0 const hasUnitAmount = tier.unitAmount !== undefined const isFree = hasUnitAmount ? // TODO: are these two mutually exclusive? check in stripe tier.unitAmount === 0 : tier.flatAmount === 0 const isTierInfinite = tier.upTo === 'inf' const numLabel = tier.upTo === 'inf' ? 'infinite requests' : `${humanNumber(tier.upTo)} ${plur('request', tier.upTo)}` const price = `$${pricingAmountToFixedString( hasUnitAmount ? tier.unitAmount! : tier.flatAmount! )}${hasUnitAmount ? ' per request' : ''}` const numDesc = isFree ? isFirst ? isTierInfinite ? `FREE for all requests per ${interval}` : `FREE for the first ${numLabel} per ${interval}` : isTierInfinite ? `FREE for all requests after that per ${interval}` : `FREE for requests up to ${numLabel} per ${interval}` : isFirst ? isTierInfinite ? `${price} per ${interval}` : `${price} for the first ${numLabel} per ${interval}` : isTierInfinite ? `${price} after that per ${interval}` : `${price} up to ${numLabel} per ${interval}` return (
{numDesc}
) })}
) : (
Unsupported pricing config. Please contact support.
)}
)} {isFreePlan && (

Try before you buy. 100% free!

)} {requestsRateLimit?.enabled && (
{isFreePlan ? ( ) : ( )} {isFreePlan ? 'Limited' : 'Rate-limited'} to{' '} {requestsRateLimit.limit} requests per{' '} {getRateLimitIntervalLabel(requestsRateLimit.interval)}
)} {plan.features && (

Features:

    {plan.features.map((feature, index) => (
  • {feature}
  • ))}
)} {requestsLineItem?.billingScheme === 'tiered' && (

{requestsLineItem.tiersMode === 'graduated' ? ( <> Requests pricing tiers use{' '} graduated pricing . ) : ( <> Requests pricing tiers use{' '} volume-based pricing . )}

)}
) } ================================================ FILE: apps/web/src/components/public-project.tsx ================================================ import type { Project } from '@agentic/platform-types' import { UTCDate } from '@date-fns/utc' import { formatDistanceToNow } from 'date-fns' import Link from 'next/link' export function PublicProject({ project }: { project: Project }) { const deployment = project.lastPublishedDeployment! if (!deployment) return null return (
{project.name}

{project.name}

{project.identifier}

{deployment.description}

{project.lastPublishedDeployment && (
{deployment.version}
Last published{' '} {formatDistanceToNow(new UTCDate(deployment.createdAt), { addSuffix: true })}
)}
) } ================================================ FILE: apps/web/src/components/supply-side-cta.tsx ================================================ 'use client' import { sanitizeSearchParams } from '@agentic/platform-core' import Link from 'next/link' import { HeroButton, type HeroButtonVariant } from '@/components/hero-button' import { Button } from '@/components/ui/button' import { GitHubIcon } from '@/icons/github' import { calendarBookingUrl, githubUrl } from '@/lib/config' import { useAgentic } from './agentic-provider' import { GitHubStarCounter } from './github-star-counter' const docsPublishingQuickStartUrl = 'https://docs.agentic.so/publishing/quickstart' export function SupplySideCTA({ variant = 'github', heroVariant = 'orange' }: { variant?: 'book-call' | 'docs' | 'github' | 'github-2' heroVariant?: HeroButtonVariant }) { const ctx = useAgentic() return (
Quick Start {variant === 'github' ? ( ) : variant === 'github-2' ? ( ) : variant === 'docs' ? ( ) : ( )}
) } ================================================ FILE: apps/web/src/components/theme-provider.tsx ================================================ 'use client' import type React from 'react' import { ThemeProvider as NextThemesProvider } from 'next-themes' export function ThemeProvider({ children, ...props }: React.ComponentProps) { return {children} } ================================================ FILE: apps/web/src/components/ui/avatar.tsx ================================================ 'use client' import * as AvatarPrimitive from '@radix-ui/react-avatar' import * as React from 'react' import { cn } from '@/lib/utils' function Avatar({ className, ...props }: React.ComponentProps) { return ( ) } function AvatarImage({ className, ...props }: React.ComponentProps) { return ( ) } function AvatarFallback({ className, ...props }: React.ComponentProps) { return ( ) } export { Avatar, AvatarFallback, AvatarImage } ================================================ FILE: apps/web/src/components/ui/breadcrumb.tsx ================================================ import { Slot } from '@radix-ui/react-slot' import { ChevronRight, MoreHorizontal } from 'lucide-react' import * as React from 'react' import { cn } from '@/lib/utils' function Breadcrumb({ ...props }: React.ComponentProps<'nav'>) { return