diff --git a/apps/api/src/api-v1/deployments/admin-get-deployment-by-identifier.ts b/apps/api/src/api-v1/deployments/admin-get-deployment-by-identifier.ts index 05f545d9..41deeb9c 100644 --- a/apps/api/src/api-v1/deployments/admin-get-deployment-by-identifier.ts +++ b/apps/api/src/api-v1/deployments/admin-get-deployment-by-identifier.ts @@ -45,15 +45,23 @@ export function registerV1AdminDeploymentsGetDeploymentByIdentifier( const { deploymentIdentifier, populate = [] } = c.req.valid('query') await aclAdmin(c) - const deployment = await tryGetDeploymentByIdentifier(c, { + const { project, ...deployment } = await tryGetDeploymentByIdentifier(c, { deploymentIdentifier, with: { - ...Object.fromEntries(populate.map((field) => [field, true])) + ...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' }) + const hasPopulateProject = populate.includes('project') + // TODO // TODO: switch from published to publishedAt? // if (deployment.published) { @@ -69,7 +77,11 @@ export function registerV1AdminDeploymentsGetDeploymentByIdentifier( // } return c.json( - parseZodSchema(schema.deploymentAdminSelectSchema, deployment) + parseZodSchema(schema.deploymentAdminSelectSchema, { + ...deployment, + ...(hasPopulateProject ? { project } : {}), + _secret: project._secret + }) ) }) } diff --git a/apps/api/src/db/schema/consumer.ts b/apps/api/src/db/schema/consumer.ts index 1b985327..1d1f2e57 100644 --- a/apps/api/src/db/schema/consumer.ts +++ b/apps/api/src/db/schema/consumer.ts @@ -88,8 +88,9 @@ export const consumers = pgTable( onDelete: 'cascade' }), - // Stripe subscription status (synced via webhooks) - stripeStatus: text(), + // 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`. diff --git a/apps/api/src/db/schema/deployment.ts b/apps/api/src/db/schema/deployment.ts index 564bd10c..a32d6333 100644 --- a/apps/api/src/db/schema/deployment.ts +++ b/apps/api/src/db/schema/deployment.ts @@ -193,7 +193,8 @@ Deployments are private to a developer or team until they are published, at whic export const deploymentAdminSelectSchema = deploymentSelectSchema .extend({ - originUrl: resolvedAgenticProjectConfigSchema.shape.originUrl + originUrl: resolvedAgenticProjectConfigSchema.shape.originUrl, + _secret: z.string().nonempty() }) .openapi('AdminDeployment') diff --git a/apps/api/src/lib/billing/upsert-stripe-subscription.ts b/apps/api/src/lib/billing/upsert-stripe-subscription.ts index 6da0cc2b..3f6b3171 100644 --- a/apps/api/src/lib/billing/upsert-stripe-subscription.ts +++ b/apps/api/src/lib/billing/upsert-stripe-subscription.ts @@ -94,6 +94,7 @@ export async function upsertStripeSubscription( ) const updateParams: Stripe.SubscriptionUpdateParams = { + collection_method: 'charge_automatically', metadata: { userId: consumer.userId, consumerId: consumer.id, @@ -259,10 +260,11 @@ export async function upsertStripeSubscription( const createParams: Stripe.SubscriptionCreateParams = { customer: stripeCustomerId, - description: `Agentic subscription to project "${project.id}"`, + description: `Agentic subscription to project "${project.identifier}"`, // TODO: coupons // coupon: filterConsumerCoupon(ctx, consumer, deployment), items, + collection_method: 'charge_automatically', metadata: { userId: consumer.userId, consumerId: consumer.id, diff --git a/apps/api/src/lib/consumers/utils.ts b/apps/api/src/lib/consumers/utils.ts index 7548199c..a79f77f9 100644 --- a/apps/api/src/lib/consumers/utils.ts +++ b/apps/api/src/lib/consumers/utils.ts @@ -1,5 +1,6 @@ import type { RawConsumerUpdate } from '@/db' +// https://docs.stripe.com/api/subscriptions/object#subscription_object-status const stripeValidSubscriptionStatuses = new Set([ 'active', 'trialing', diff --git a/apps/gateway/src/lib/enforce-rate-limit.ts b/apps/gateway/src/lib/enforce-rate-limit.ts index 25dfec01..8b2093e6 100644 --- a/apps/gateway/src/lib/enforce-rate-limit.ts +++ b/apps/gateway/src/lib/enforce-rate-limit.ts @@ -1,12 +1,28 @@ +import { assert } from '@agentic/platform-core' + import type { Context } from './types' export async function enforceRateLimit( ctx: Context, - {}: { + { + id, + interval, + maxPerInterval, + method, + pathname + }: { id: string interval: number maxPerInterval: number method: string pathname: string } -) {} +) { + // TODO + assert(ctx, 500, 'not implemented') + assert(id, 500, 'not implemented') + assert(interval > 0, 500, 'not implemented') + assert(maxPerInterval >= 0, 500, 'not implemented') + assert(method, 500, 'not implemented') + assert(pathname, 500, 'not implemented') +} diff --git a/apps/gateway/src/lib/get-consumer.ts b/apps/gateway/src/lib/get-consumer.ts index 623f81be..1f611a60 100644 --- a/apps/gateway/src/lib/get-consumer.ts +++ b/apps/gateway/src/lib/get-consumer.ts @@ -1,14 +1,14 @@ -import type { Consumer } from '@agentic/platform-api-client' import { assert } from '@agentic/platform-core' -import type { Context } from './types' +import type { AdminConsumer, Context } from './types' export async function getConsumer( ctx: Context, token: string -): Promise { +): Promise { const consumer = await ctx.client.adminGetConsumerByToken({ - token + token, + populate: ['user'] }) assert(consumer, 404, `API token not found "${token}"`) diff --git a/apps/gateway/src/lib/get-deployment.ts b/apps/gateway/src/lib/get-deployment.ts index 9924e833..b4edf3c3 100644 --- a/apps/gateway/src/lib/get-deployment.ts +++ b/apps/gateway/src/lib/get-deployment.ts @@ -1,4 +1,4 @@ -import type { AdminDeployment } from '@agentic/platform-api-client' +import type { AdminDeployment } from '@agentic/platform-types' import { assert } from '@agentic/platform-core' import { parseFaasIdentifier } from '@agentic/platform-validators' diff --git a/apps/gateway/src/lib/resolve-origin-request.ts b/apps/gateway/src/lib/resolve-origin-request.ts index 4c86a7fc..39622988 100644 --- a/apps/gateway/src/lib/resolve-origin-request.ts +++ b/apps/gateway/src/lib/resolve-origin-request.ts @@ -1,13 +1,7 @@ -import type { - AdminDeployment, - Consumer, - PricingPlan, - RateLimit, - Tool -} from '@agentic/platform-types' +import type { PricingPlan, RateLimit } from '@agentic/platform-types' import { assert } from '@agentic/platform-core' -import type { Context } from './types' +import type { AdminConsumer, Context, ResolvedOriginRequest } from './types' import { getConsumer } from './get-consumer' import { getDeployment } from './get-deployment' import { getTool } from './get-tool' @@ -20,11 +14,12 @@ import { updateOriginRequest } from './update-origin-request' * Also ensures that the request is valid, enforces rate limits, and adds proxy- * specific headers to the origin request. */ -export async function resolveOriginRequest(ctx: Context) { +export async function resolveOriginRequest( + ctx: Context +): Promise { const { req } = ctx - const ip = req.headers.get('cf-connecting-ip') + const ip = req.headers.get('cf-connecting-ip') || undefined const requestUrl = new URL(req.url) - const date = Date.now() const { search, pathname } = requestUrl const method = req.method.toLowerCase() @@ -36,18 +31,19 @@ export async function resolveOriginRequest(ctx: Context) { deployment, toolPath }) - console.log('rqeuest', { + + console.log('request', { method, pathname, search, - deployment: deployment.identifier, + deploymentIdentifier: deployment.identifier, toolPath, tool }) - let reportUsage = true let pricingPlan: PricingPlan | undefined - let consumer: Consumer | undefined + let consumer: AdminConsumer | undefined + let reportUsage = true const token = (req.headers.get('authorization') || '') .replace(/^Bearer /i, '') @@ -177,32 +173,26 @@ export async function resolveOriginRequest(ctx: Context) { // TODO: what do we want the API gateway's interface to be? // - support both MCP and OpenAPI / raw? - const originUrl = `${deployment.originUrl}${toolPath}${search}` - console.log('originUrl', originUrl) + let originRequest: Request | undefined + if ( + deployment.originAdapter.type === 'openapi' || + deployment.originAdapter.type === 'raw' + ) { + const originRequestUrl = `${deployment.originUrl}${toolPath}${search}` + console.log('originRequestUrl', originRequestUrl) - const originReq = new Request(originUrl, req) - updateOriginRequest(originReq, { consumer, deployment, ip }) + originRequest = new Request(originRequestUrl, req) + updateOriginRequest(originRequest, { consumer, deployment, ip }) + } return { - originReq, - deployment: deployment.id, - project: deployment.project, - tool, + originRequest, + deployment, consumer, - date, + tool, ip, method, - plan: pricingPlan ? pricingPlan.slug : null, + pricingPlanSlug: pricingPlan?.slug, reportUsage } } - -export interface ResolvedOriginRequest { - originRequest?: Request - deployment: AdminDeployment - tool: Tool - consumer: Consumer | undefined - date: number - ip: string - method: string -} diff --git a/apps/gateway/src/lib/types.ts b/apps/gateway/src/lib/types.ts index 2bf527c6..16765e6b 100644 --- a/apps/gateway/src/lib/types.ts +++ b/apps/gateway/src/lib/types.ts @@ -1,4 +1,10 @@ import type { AgenticApiClient } from '@agentic/platform-api-client' +import type { + AdminDeployment, + Consumer, + Tool, + User +} from '@agentic/platform-types' import type { AgenticEnv } from './env' @@ -7,3 +13,18 @@ export type Context = ExecutionContext & { env: AgenticEnv client: AgenticApiClient } + +export interface ResolvedOriginRequest { + originRequest?: Request + deployment: AdminDeployment + consumer?: AdminConsumer + tool: Tool + method: string + reportUsage: boolean + ip?: string + pricingPlanSlug?: string +} + +export type AdminConsumer = Consumer & { + user: User +} diff --git a/apps/gateway/src/lib/update-origin-request.ts b/apps/gateway/src/lib/update-origin-request.ts new file mode 100644 index 00000000..cda22bda --- /dev/null +++ b/apps/gateway/src/lib/update-origin-request.ts @@ -0,0 +1,88 @@ +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 + }: { + deployment: AdminDeployment + consumer?: AdminConsumer + } +) { + // 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 API gateway 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-consumer', consumer.id) + originRequest.headers.set('x-agentic-user', consumer.user.id) + + originRequest.headers.set( + 'x-agentic-is-subscription-active', + consumer.isStripeSubscriptionActive.toString() + ) + originRequest.headers.set( + 'x-agentic-subscription-status', + consumer.stripeStatus + ) + 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 + ) + + if (consumer.plan) { + originRequest.headers.set('x-agentic-plan', consumer.plan) + } + + if (consumer.user.name) { + originRequest.headers.set('x-agentic-user-name', consumer.user.name) + } + } + + // 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) + + originRequest.headers.set('x-agentic-proxy-secret', deployment._secret) +} diff --git a/packages/types/src/openapi.d.ts b/packages/types/src/openapi.d.ts index bb2a2767..f3580a72 100644 --- a/packages/types/src/openapi.d.ts +++ b/packages/types/src/openapi.d.ts @@ -451,7 +451,7 @@ export interface components { projectId: string; /** @description Deployment id (e.g. "depl_tz4a98xxat96iws9zmbrgj3a") */ deploymentId: string; - stripeStatus?: string; + stripeStatus: string; isStripeSubscriptionActive: boolean; }; /** @description Public deployment identifier (e.g. "namespace/project-name@{hash|version|latest}") */ @@ -717,6 +717,7 @@ export interface components { * NOTE: 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. */ originUrl: string; + _secret: string; }; }; responses: { diff --git a/readme.md b/readme.md index fa693077..a69bcebf 100644 --- a/readme.md +++ b/readme.md @@ -37,6 +37,8 @@ - same for pricing plan line-items - replace `ms` package - API gateway MCP server vs OpenAPI API gateway +- share hono middleware and utils across apps/api and apps/gateway + - or combine these together? ehhhh ## License