pull/715/head
Travis Fischer 2025-05-19 16:04:45 +07:00
rodzic 9c0975f170
commit e2d2303275
11 zmienionych plików z 259 dodań i 93 usunięć

Wyświetl plik

@ -1,20 +1,22 @@
import { validators } from '@agentic/validators'
import { createRoute, type OpenAPIHono } from '@hono/zod-openapi'
import semver from 'semver'
import type { AuthenticatedEnv } from '@/lib/types'
import { db, eq, schema } from '@/db'
import { acl } from '@/lib/acl'
import { publishDeployment } from '@/lib/deployments/publish-deployment'
import { resolveDeploymentVersion } from '@/lib/deployments/resolve-deployment-version'
import { ensureAuthUser } from '@/lib/ensure-auth-user'
import {
openapiAuthenticatedSecuritySchemas,
openapiErrorResponse404,
openapiErrorResponse409,
openapiErrorResponse410,
openapiErrorResponses
} from '@/lib/openapi-utils'
import { assert, parseZodSchema, sha256 } from '@/lib/utils'
import { createDeploymentQuerySchema } from './schemas'
const route = createRoute({
description: 'Creates a new deployment within a project.',
tags: ['deployments'],
@ -23,6 +25,7 @@ const route = createRoute({
path: 'deployments',
security: openapiAuthenticatedSecuritySchemas,
request: {
query: createDeploymentQuerySchema,
body: {
required: true,
content: {
@ -43,8 +46,7 @@ const route = createRoute({
},
...openapiErrorResponses,
...openapiErrorResponse404,
...openapiErrorResponse409,
...openapiErrorResponse410
...openapiErrorResponse409
}
})
@ -53,13 +55,14 @@ export function registerV1DeploymentsCreateDeployment(
) {
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 { projectId } = body
// validatePricingPlans(ctx, pricingPlans)
// TODO: OpenAPI support
// TODO: validate OpenAPI origin schema
const project = await db.query.projects.findFirst({
where: eq(schema.projects.id, projectId),
@ -80,26 +83,23 @@ export function registerV1DeploymentsCreateDeployment(
)
let { version } = body
if (version) {
version = semver.clean(version) ?? undefined
if (publish) {
assert(
version && semver.valid(version),
version,
400,
`Invalid semver version "${version}"`
)
const lastPublishedVersion =
project.lastPublishedDeployment?.version ?? '0.0.0'
assert(
semver.gt(version, lastPublishedVersion),
400,
`Semver version "${version}" must be greater than last published version "${lastPublishedVersion}"`
`Deployment version is required to publish deployment "${deploymentId}"`
)
}
const [[deployment]] = await db.transaction(async (tx) => {
if (version) {
version = resolveDeploymentVersion({
deploymentId,
project,
version
})
}
let [[deployment]] = await db.transaction(async (tx) => {
return Promise.all([
// Create the deployment
tx
@ -126,6 +126,15 @@ export function registerV1DeploymentsCreateDeployment(
})
assert(deployment, 500, `Failed to create deployment "${deploymentId}"`)
if (publish) {
deployment = await publishDeployment(c, {
deployment,
version: deployment.version!
})
}
// TODO: validate deployment originUrl, originAdapter, originSchema, and
// originSchemaVersion
return c.json(parseZodSchema(schema.deploymentSelectSchema, deployment))
})
}

Wyświetl plik

@ -1,9 +1,9 @@
import { createRoute, type OpenAPIHono } from '@hono/zod-openapi'
import semver from 'semver'
import type { AuthenticatedEnv } from '@/lib/types'
import { db, eq, schema } from '@/db'
import { schema } from '@/db'
import { acl } from '@/lib/acl'
import { publishDeployment } from '@/lib/deployments/publish-deployment'
import {
openapiAuthenticatedSecuritySchemas,
openapiErrorResponse404,
@ -51,68 +51,20 @@ export function registerV1DeploymentsPublishDeployment(
) {
return app.openapi(route, async (c) => {
const { deploymentId } = c.req.valid('param')
const body = c.req.valid('json')
const version = semver.clean(body.version)
assert(version, 400, `Invalid semver version "${body.version}"`)
const { version } = c.req.valid('json')
// First ensure the deployment exists and the user has access to it
let deployment = await tryGetDeployment(c, deploymentId)
const deployment = await tryGetDeployment(c, deploymentId)
assert(deployment, 404, `Deployment not found "${deploymentId}"`)
await acl(c, deployment, { label: 'Deployment' })
const project = await db.query.projects.findFirst({
where: eq(schema.projects.id, deployment.projectId),
with: {
lastPublishedDeployment: true
}
const publishedDeployment = await publishDeployment(c, {
deployment,
version
})
assert(project, 404, `Project not found "${deployment.projectId}"`)
await acl(c, project, { label: 'Project' })
const lastPublishedVersion =
project.lastPublishedDeployment?.version || '0.0.0'
assert(
semver.valid(version),
400,
`Invalid semver version "${version}" for deployment "${deployment.id}"`
return c.json(
parseZodSchema(schema.deploymentSelectSchema, publishedDeployment)
)
assert(
semver.gt(version, lastPublishedVersion),
400,
`Invalid semver version: "${version}" must be greater than current published version "${lastPublishedVersion}" for deployment "${deployment.id}"`
)
// TODO: enforce certain 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
;[[deployment]] = await db.transaction(async (tx) => {
return Promise.all([
// Update the deployment
tx
.update(schema.deployments)
.set({
published: true,
version
})
.where(eq(schema.deployments.id, deploymentId))
.returning(),
tx
.update(schema.projects)
.set({
lastPublishedDeploymentId: deploymentId
})
.where(eq(schema.projects.id, project.id))
// TODO: add publishDeploymentLogEntry
])
})
assert(deployment, 500, `Failed to update deployment "${deploymentId}"`)
return c.json(parseZodSchema(schema.deploymentSelectSchema, deployment))
})
}

Wyświetl plik

@ -13,6 +13,10 @@ export const deploymentIdParamsSchema = z.object({
})
})
export const createDeploymentQuerySchema = z.object({
publish: z.boolean().default(false).optional()
})
export const filterDeploymentSchema = z.object({
projectId: projectIdSchema.optional()
})

Wyświetl plik

@ -11,10 +11,10 @@ import { z } from '@hono/zod-openapi'
import { projects } from './project'
import {
type DeploymentOriginAdapter,
deploymentOriginAdapterSchema,
type PricingPlanList,
pricingPlanListSchema
// type Coupon,
// couponSchema,
} from './schemas'
import { teams, teamSelectSchema } from './team'
import { users, userSelectSchema } from './user'
@ -43,6 +43,7 @@ export const deployments = pgTable(
description: text().default('').notNull(),
readme: text().default('').notNull(),
iconUrl: text(),
userId: cuid()
.notNull()
@ -57,13 +58,21 @@ export const deployments = pgTable(
// TODO: Tool definitions or OpenAPI spec
// services: jsonb().$type<Service[]>().default([]),
// TODO: metadata config (logo, keywords, etc)
// TODO: metadata config (logo, keywords, examples, etc)
// TODO: openapi spec or tool definitions or mcp adapter
// TODO: webhooks
// TODO: third-party auth provider config?
// 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.
// Backend API URL
// Origin API URL
originUrl: text().notNull(),
// Origin API adapter config (openapi, mcp, hosted externally or internally, etc)
originAdapter: jsonb().$type<DeploymentOriginAdapter>().notNull(),
// Array<PricingPlan>
pricingPlans: jsonb().$type<PricingPlanList>().notNull()
@ -123,8 +132,8 @@ export const deploymentSelectSchema = createSelectSchema(deployments, {
message: 'Invalid deployment hash'
}),
pricingPlans: pricingPlanListSchema
// coupons: z.array(couponSchema)
pricingPlans: pricingPlanListSchema,
originAdapter: deploymentOriginAdapterSchema
})
.omit({
originUrl: true
@ -152,11 +161,24 @@ export const deploymentInsertSchema = createInsertSchema(deployments, {
message: 'Invalid project id'
}),
originUrl: (schema) => schema.url(),
iconUrl: (schema) =>
schema
.url()
.describe(
'Logo image URL to use for this delpoyment. Logos should have a square aspect ratio.'
),
pricingPlans: pricingPlanListSchema
originUrl: (schema) =>
schema.url().describe(`Base URL of the externally hosted origin API server.
// TODOp
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.`),
pricingPlans: pricingPlanListSchema.describe(
'List of PricingPlans should be available as subscriptions for this deployment.'
),
originAdapter: deploymentOriginAdapterSchema.optional()
// TODO
// coupons: z.array(couponSchema).optional()
})
.omit({

Wyświetl plik

@ -426,3 +426,64 @@ export type StripeSubscriptionItemIdMap = z.infer<
// })
// .openapi('Coupon')
// export type Coupon = z.infer<typeof couponSchema>
export const deploymentOriginAdapterLocationSchema = z.enum([
'external'
// 'internal'
])
export type DeploymentOriginAdapterLocation = z.infer<
typeof deploymentOriginAdapterLocationSchema
>
// export const deploymentOriginAdapterInternalTypeSchema = z.enum([
// // 'docker',
// // 'mcp'
// // 'python-fastapi'
// // etc
// ])
// export type DeploymentOriginAdapterInternalType = z.infer<
// typeof deploymentOriginAdapterInternalTypeSchema
// >
export const commonDeploymentOriginAdapterSchema = z.object({
location: deploymentOriginAdapterLocationSchema
// TODO: Add support for `internal` hosted API servers
// internalType: deploymentOriginAdapterInternalTypeSchema.optional()
})
// TODO: add future support for:
// - external mcp
// - internal docker
// - internal mcp
// - internal http
export const deploymentOriginAdapterSchema = z
.discriminatedUnion('type', [
z
.object({
type: z.literal('openapi'),
version: z.enum(['3.0', '3.1']),
// TODO: Make sure origin API servers are hidden in this embedded OpenAPI spec
spec: z.any().describe('JSON OpenAPI spec for the origin API server.')
})
.merge(commonDeploymentOriginAdapterSchema),
z
.object({
type: z.literal('raw')
})
.merge(commonDeploymentOriginAdapterSchema)
])
.default({
location: 'external',
type: 'raw'
})
.describe(
`Deployment origin API adapter is used to configure the origin API server downstream from Agentic's API gateway. It specifies whether the origin API server denoted by \`originUrl\` is hosted externally or deployed internally to Agentic's infrastructure. It also specifies the format for how origin tools / services are defined: either as an OpenAPI spec, an MCP server, or as a raw HTTP REST API.
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.`
)
.openapi('DeploymentOriginAdapter')
export type DeploymentOriginAdapter = z.infer<
typeof deploymentOriginAdapterSchema
>

Wyświetl plik

@ -51,7 +51,7 @@ export const users = pgTable(
isStripeConnectEnabledByDefault: boolean().default(true).notNull(),
// third-party auth providers
providers: jsonb().$type<AuthProviders>().default({}).notNull(),
authProviders: jsonb().$type<AuthProviders>().default({}).notNull(),
stripeCustomerId: stripeId().unique()
},
@ -70,7 +70,7 @@ export const usersRelations = relations(users, ({ many }) => ({
}))
export const userSelectSchema = createSelectSchema(users, {
providers: publicAuthProvidersSchema
authProviders: publicAuthProvidersSchema
})
.omit({ password: true, emailConfirmToken: true, passwordResetToken: true })
.strip()

Wyświetl plik

@ -4,7 +4,7 @@ import type { LogLevel } from '@/lib/logger'
import type { LogEntry, RawLogEntry, RawUser, User } from './types'
type UserKeys = Exclude<keyof User & keyof RawUser, 'providers'>
type UserKeys = Exclude<keyof User & keyof RawUser, 'authProviders'>
type LogEntryKeys = keyof RawLogEntry & keyof LogEntry
test('User types are compatible', () => {

Wyświetl plik

@ -5,6 +5,7 @@ import type {
InferSelectModel
} from '@fisch0920/drizzle-orm'
import type { z } from '@hono/zod-openapi'
import type { Simplify } from 'type-fest'
import type * as schema from './schema'
@ -35,7 +36,12 @@ export type ProjectWithLastPublishedDeployment = BuildQueryResult<
Tables['projects'],
{ with: { lastPublishedDeployment: true } }
>
export type RawProject = InferSelectModel<typeof schema.projects>
export type RawProject = Simplify<
InferSelectModel<typeof schema.projects> & {
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<typeof schema.deploymentSelectSchema>
export type DeploymentWithProject = BuildQueryResult<
@ -43,7 +49,11 @@ export type DeploymentWithProject = BuildQueryResult<
Tables['deployments'],
{ with: { project: true } }
>
export type RawDeployment = InferSelectModel<typeof schema.deployments>
export type RawDeployment = Simplify<
InferSelectModel<typeof schema.deployments> & {
project?: RawProject | null // TODO: remove null (requires drizzle-orm changes)
}
>
export type Consumer = z.infer<typeof schema.consumerSelectSchema>
export type ConsumerWithProjectAndDeployment = BuildQueryResult<
@ -51,7 +61,13 @@ export type ConsumerWithProjectAndDeployment = BuildQueryResult<
Tables['consumers'],
{ with: { project: true; deployment: true } }
>
export type RawConsumer = InferSelectModel<typeof schema.consumers>
export type RawConsumer = Simplify<
InferSelectModel<typeof schema.consumers> & {
user?: RawUser | null // TODO: remove null (requires drizzle-orm changes)
project?: RawProject | null // TODO: remove null (requires drizzle-orm changes)
deployment?: RawDeployment | null // TODO: remove null (requires drizzle-orm changes)
}
>
export type ConsumerUpdate = Partial<
Omit<
InferInsertModel<typeof schema.consumers>,

Wyświetl plik

@ -0,0 +1,68 @@
import { db, eq, type RawDeployment, schema } from '@/db'
import { acl } from '@/lib/acl'
import { assert } from '@/lib/utils'
import type { AuthenticatedContext } from '../types'
import { resolveDeploymentVersion } from './resolve-deployment-version'
export async function publishDeployment(
ctx: AuthenticatedContext,
{
deployment,
version: rawVersion
}: {
deployment: RawDeployment
version: string
}
): Promise<RawDeployment> {
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 = resolveDeploymentVersion({
deploymentId: deployment.id,
project,
version: rawVersion
})
// TODO: enforce certain 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(),
tx
.update(schema.projects)
.set({
lastPublishedDeploymentId: deployment.id
})
.where(eq(schema.projects.id, project.id))
// TODO: add publishDeploymentLogEntry
])
})
assert(
updatedDeployment,
500,
`Failed to update deployment "${deployment.id}"`
)
return updatedDeployment
}

Wyświetl plik

@ -0,0 +1,32 @@
import semver from 'semver'
import type { RawProject } from '@/db/types'
import { assert } from '@/lib/utils'
export function resolveDeploymentVersion({
deploymentId,
version: rawVersion,
project
}: {
deploymentId: 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 "${deploymentId}"`
)
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 "${deploymentId}"`
)
return version
}

Wyświetl plik

@ -24,6 +24,8 @@
- https://github.com/NangoHQ/nango
- https://github.com/transitive-bullshit?submit=Search&q=oauth&tab=stars&type=&sort=&direction=&submit=Search
- clerk / workos / auth0
- db
- replace `enabled` with soft `deletedAt` timestamp
## License