pull/715/head
Travis Fischer 2025-05-26 16:36:44 +07:00
rodzic 6f35cffae9
commit 8b039708ff
15 zmienionych plików z 205 dodań i 170 usunięć

Wyświetl plik

@ -104,7 +104,7 @@ export const authRouter = issuer({
},
partialUser: {
email: ghUser.email,
emailVerified: true,
isEmailVerified: true,
name: ghUser.name || undefined,
username: ghUser.login.toLowerCase(),
image: ghUser.avatar_url
@ -118,7 +118,7 @@ export const authRouter = issuer({
},
partialUser: {
email: value.email,
emailVerified: true
isEmailVerified: true
}
})
} else {

Wyświetl plik

@ -1,6 +1,8 @@
import {
agenticProjectConfigSchema,
type DeploymentOriginAdapter,
deploymentOriginAdapterSchema,
pricingIntervalListSchema,
type PricingPlanList,
pricingPlanListSchema
} from '@agentic/platform-schemas'
@ -29,6 +31,7 @@ import {
createUpdateSchema,
deploymentIdentifier,
deploymentPrimaryId,
pricingIntervalEnum,
projectId,
teamId,
timestamps,
@ -65,11 +68,10 @@ export const deployments = pgTable(
onDelete: 'cascade'
}),
// TODO: Tool definitions or OpenAPI spec
// services: jsonb().$type<Service[]>().default([]),
// TODO: Tool definitions
// tools: jsonb().$type<Tool[]>().default([]),
// TODO: metadata config (logo, keywords, examples, etc)
// TODO: openapi spec or tool definitions or mcp adapter
// TODO: webhooks
// TODO: third-party auth provider config
// NOTE: will need consumer.authProviders as well as user.authProviders for
@ -84,7 +86,10 @@ export const deployments = pgTable(
originAdapter: jsonb().$type<DeploymentOriginAdapter>().notNull(),
// Array<PricingPlan>
pricingPlans: jsonb().$type<PricingPlanList>().notNull()
pricingPlans: jsonb().$type<PricingPlanList>().notNull(),
// Which pricing intervals are supported for subscriptions to this project
pricingIntervals: pricingIntervalEnum().array().default(['month']).notNull()
// coupons: jsonb().$type<Coupon[]>().default([]).notNull()
},
@ -135,8 +140,9 @@ export const deploymentSelectSchema = createSelectSchema(deployments, {
message: 'Invalid deployment hash'
}),
originAdapter: deploymentOriginAdapterSchema,
pricingPlans: pricingPlanListSchema,
originAdapter: deploymentOriginAdapterSchema
pricingIntervals: pricingIntervalListSchema
})
.omit({
originUrl: true
@ -159,33 +165,7 @@ export const deploymentSelectSchema = createSelectSchema(deployments, {
.openapi('Deployment')
export const deploymentInsertSchema = createInsertSchema(deployments, {
projectId: projectIdSchema,
iconUrl: (schema) =>
schema
.url()
.describe(
'Logo image URL to use for this deployment. Logos should have a square aspect ratio.'
),
sourceUrl: (schema) => schema.url(),
originUrl: (schema) =>
schema.url().describe(`Base URL of the externally hosted origin API server.
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.default({
location: 'external',
type: 'raw'
})
// .optional()
// TODO
// coupons: z.array(couponSchema).optional()
projectId: projectIdSchema
})
.omit({
id: true,
@ -195,8 +175,15 @@ NOTE: Agentic currently only supports \`external\` API servers. If you'd like to
userId: true,
teamId: true
})
.extend({
...agenticProjectConfigSchema.shape
})
.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,

Wyświetl plik

@ -10,7 +10,6 @@ import {
import { validators } from '@agentic/platform-validators'
import { relations } from '@fisch0920/drizzle-orm'
import {
boolean,
index,
integer,
jsonb,
@ -18,7 +17,6 @@ import {
text,
uniqueIndex
} from '@fisch0920/drizzle-orm/pg-core'
import { z } from '@hono/zod-openapi'
import {
deploymentIdSchema,
@ -69,21 +67,18 @@ export const projects = pgTable(
applicationFeePercent: integer().default(20).notNull(),
// TODO: This is going to need to vary from dev to prod
isStripeConnectEnabled: boolean().default(false).notNull(),
// Which pricing intervals are supported for subscriptions to this project
pricingIntervals: pricingIntervalEnum()
.array()
.default(['month'])
.notNull(),
//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
// 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
@ -179,7 +174,6 @@ export const projectSelectSchema = createSelectSchema(projects, {
applicationFeePercent: (schema) => schema.nonnegative(),
pricingIntervals: z.array(pricingIntervalSchema).nonempty(),
defaultPricingInterval: pricingIntervalSchema,
_stripeProductIdMap: stripeProductIdMapSchema,

Wyświetl plik

@ -19,8 +19,6 @@ import {
userRoleEnum
} from './common'
// This table is mostly managed by better-auth.
export const users = pgTable(
'users',
{
@ -32,10 +30,10 @@ export const users = pgTable(
name: text(),
email: text().notNull().unique(),
emailVerified: boolean().default(false).notNull(),
isEmailVerified: boolean().default(false).notNull(),
image: text(),
isStripeConnectEnabledByDefault: boolean().default(true).notNull(),
//isStripeConnectEnabledByDefault: boolean().default(true).notNull(),
stripeCustomerId: stripeId()
},
@ -59,7 +57,7 @@ export const userSelectSchema = createSelectSchema(users)
export const userUpdateSchema = createUpdateSchema(users)
.pick({
name: true,
image: true,
isStripeConnectEnabledByDefault: true
image: true
//isStripeConnectEnabledByDefault: true
})
.strict()

Wyświetl plik

@ -1,8 +1,6 @@
import type {
PricingInterval,
PricingPlan,
PricingPlanLineItem,
PricingPlanList
PricingPlanLineItem
} from '@agentic/platform-schemas'
import { hashObject } from '@agentic/platform-core'
@ -62,30 +60,3 @@ export function getStripePriceIdForPricingPlanLineItem({
return project._stripePriceIdMap[pricingPlanLineItemHash]
}
export function getPricingPlansByInterval({
pricingInterval,
pricingPlans
}: {
pricingInterval: PricingInterval
pricingPlans: PricingPlanList
}): PricingPlan[] {
return pricingPlans.filter(
(pricingPlan) =>
pricingPlan.interval === undefined ||
pricingPlan.interval === pricingInterval
)
}
const pricingIntervalToLabelMap: Record<PricingInterval, string> = {
day: 'daily',
week: 'weekly',
month: 'monthly',
year: 'yearly'
}
export function getLabelForPricingInterval(
pricingInterval: PricingInterval
): string {
return pricingIntervalToLabelMap[pricingInterval]
}

Wyświetl plik

@ -38,7 +38,10 @@ export async function upsertOrLinkUserAccount({
partialUser: Simplify<
SetRequired<
Partial<
Pick<RawUser, 'email' | 'name' | 'username' | 'image' | 'emailVerified'>
Pick<
RawUser,
'email' | 'name' | 'username' | 'image' | 'isEmailVerified'
>
>,
'email'
>

Wyświetl plik

@ -1,17 +1,16 @@
import type {
PricingPlan,
PricingPlanLineItem
} from '@agentic/platform-schemas'
import type Stripe from 'stripe'
import { assert } from '@agentic/platform-core'
import {
getLabelForPricingInterval,
type PricingPlan,
type PricingPlanLineItem
} from '@agentic/platform-schemas'
import pAll from 'p-all'
import {
db,
eq,
getLabelForPricingInterval,
getPricingPlanLineItemHashForStripePrice,
getPricingPlansByInterval,
type RawDeployment,
type RawProject,
schema
@ -28,8 +27,12 @@ import { stripe } from '@/lib/external/stripe'
* `_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 in case they're the same across deployments.
*
* @note This function assumes that the deployment's pricing config has already
* been validated.
*/
export async function upsertStripePricing({
deployment,
@ -244,25 +247,6 @@ export async function upsertStripePricing({
}
const upserts: Array<() => Promise<void>> = []
// Validate deployment pricing plans to ensure they contain at least one valid
// plan per pricing interval configured on the project.
// TODO: move some of this `pricingPlans` validation to a separate function?
// We really wouldn't want to create some resources and then fail partway when
// this validation or some of the validation above fails.
for (const pricingInterval of project.pricingIntervals) {
const pricingPlans = getPricingPlansByInterval({
pricingInterval,
pricingPlans: deployment.pricingPlans
})
assert(
pricingPlans.length > 0,
400,
`Invalid pricing config for deployment "${deployment.id}": no pricing plans for interval "${pricingInterval}"`
)
}
for (const pricingPlan of deployment.pricingPlans) {
for (const pricingPlanLineItem of pricingPlan.lineItems) {
upserts.push(() =>

Wyświetl plik

@ -119,7 +119,7 @@ export async function upsertStripeSubscription(
assert(
priceId,
500,
`Error updating stripe subscription: missing expected Stripe Price for plan "${pricingPlan.slug}" line item "${lineItem.slug}"`
`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
@ -199,9 +199,10 @@ export async function upsertStripeSubscription(
updateParams.cancel_at_period_end = true
}
if (project.isStripeConnectEnabled && project.applicationFeePercent > 0) {
updateParams.application_fee_percent = project.applicationFeePercent
}
// TODO: Stripe Connect
// if (project.isStripeConnectEnabled && project.applicationFeePercent > 0) {
// updateParams.application_fee_percent = project.applicationFeePercent
// }
subscription = await stripe.subscriptions.update(
consumer._stripeSubscriptionId,
@ -274,9 +275,10 @@ export async function upsertStripeSubscription(
createParams.trial_period_days = pricingPlan.trialPeriodDays
}
if (project.isStripeConnectEnabled && project.applicationFeePercent > 0) {
createParams.application_fee_percent = project.applicationFeePercent
}
// 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(
@ -321,7 +323,7 @@ export async function upsertStripeSubscription(
assert(
stripeSubscriptionItem,
500,
`Error post-processing stripe subscription for line item "${lineItem.slug}" on plan "${pricingPlan.slug}"`
`Error post-processing stripe subscription for line-item "${lineItem.slug}" on plan "${pricingPlan.slug}"`
)
consumerUpdate._stripeSubscriptionItemIdMap![lineItem.slug] =
@ -329,7 +331,7 @@ export async function upsertStripeSubscription(
assert(
consumerUpdate._stripeSubscriptionItemIdMap![lineItem.slug],
500,
`Error post-processing stripe subscription for line item "${lineItem.slug}" on plan "${pricingPlan.slug}"`
`Error post-processing stripe subscription for line-item "${lineItem.slug}" on plan "${pricingPlan.slug}"`
)
}
}

Wyświetl plik

@ -320,9 +320,8 @@ export interface components {
role: "user" | "admin";
name?: string;
email: string;
emailVerified: boolean;
isEmailVerified: boolean;
image?: string;
isStripeConnectEnabledByDefault: boolean;
stripeCustomerId?: string;
};
Team: {
@ -367,8 +366,6 @@ export interface components {
/** @description Deployment id (e.g. "depl_tz4a98xxat96iws9zmbrgj3a") */
lastDeploymentId?: string;
applicationFeePercent: number;
isStripeConnectEnabled: boolean;
pricingIntervals: components["schemas"]["PricingInterval"][];
defaultPricingInterval: components["schemas"]["PricingInterval"];
/** @enum {string} */
pricingCurrency: "usd";
@ -419,7 +416,9 @@ export interface components {
/** @example API calls */
label: string;
RateLimit: {
interval: number;
/** @description The interval at which the rate limit is applied. Either a number in seconds or a valid [ms](https://github.com/vercel/ms) string (eg, "10s", "1m", "1h", "1d", "1w", "1y", etc). */
interval: number | string;
/** @description Maximum number of operations per interval (unitless). */
maxPerInterval: number;
};
PricingPlanTier: {
@ -427,17 +426,15 @@ export interface components {
flatAmount?: number;
upTo: number | "inf";
};
/** @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 metered usage. */
/** @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. */
PricingPlanLineItem: {
slug: string | "base" | "requests";
interval?: components["schemas"]["PricingInterval"];
label?: components["schemas"]["label"];
/** @enum {string} */
usageType: "licensed";
amount: number;
} | {
slug: string | "base" | "requests";
interval?: components["schemas"]["PricingInterval"];
label?: components["schemas"]["label"];
/** @enum {string} */
usageType: "metered";
@ -461,11 +458,13 @@ export interface components {
name: components["schemas"]["name"];
slug: components["schemas"]["slug"];
interval?: components["schemas"]["PricingInterval"];
desc?: string;
description?: string;
features?: string[];
trialPeriodDays?: number;
lineItems: components["schemas"]["PricingPlanLineItem"][];
};
/** @description List of billing intervals for subscriptions. */
PricingIntervalList: components["schemas"]["PricingInterval"][];
Deployment: {
/** @description Deployment id (e.g. "depl_tz4a98xxat96iws9zmbrgj3a") */
id: string;
@ -489,6 +488,7 @@ export interface components {
originAdapter: components["schemas"]["DeploymentOriginAdapter"];
/** @description List of PricingPlans */
pricingPlans: components["schemas"]["PricingPlan"][];
pricingIntervals: components["schemas"]["PricingIntervalList"];
};
};
responses: {
@ -608,7 +608,6 @@ export interface operations {
"application/json": {
name?: string;
image?: string;
isStripeConnectEnabledByDefault?: boolean;
};
};
};
@ -1270,27 +1269,50 @@ export interface operations {
identifier: string;
version?: string;
published?: boolean;
/** @description A short description of the project. */
description?: string;
/** @description A readme documenting the project (supports GitHub-flavored markdown). */
readme?: string;
/**
* Format: uri
* @description Logo image URL to use for this deployment. Logos should have a square aspect ratio.
* @description Optional logo image URL to use for the project. Logos should have a square aspect ratio.
*/
iconUrl?: string;
/** Format: uri */
/**
* Format: uri
* @description Optional URL to the source code of the project (eg, GitHub repo).
*/
sourceUrl?: string;
/** @description Project id (e.g. "proj_tz4a98xxat96iws9zmbrgj3a") */
projectId: string;
/**
* Format: uri
* @description Base URL of the externally hosted origin API server.
* @description Required base URL of the externally hosted origin API server. Must be a valid `https` URL.
*
* 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;
originAdapter?: components["schemas"]["DeploymentOriginAdapter"] & unknown;
/** @description List of PricingPlans should be available as subscriptions for this deployment. */
pricingPlans: components["schemas"]["PricingPlan"][];
/**
* @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
* }
* ]
* }
* ]
*/
pricingPlans?: components["schemas"]["PricingPlan"][];
pricingIntervals?: components["schemas"]["PricingIntervalList"] & unknown;
/** @description Name of the project. */
name: string;
};
};
};

Wyświetl plik

@ -2,7 +2,7 @@ import { z } from '@hono/zod-openapi'
import {
deploymentOriginAdapterSchema,
pricingIntervalSchema,
pricingIntervalListSchema,
type PricingPlan,
pricingPlanListSchema
} from './schemas'
@ -54,13 +54,6 @@ export const agenticProjectConfigSchema = z.object({
)
.optional(),
/** Optional URL to the source code of the project. */
sourceUrl: z
.string()
.url()
.optional()
.describe('Optional URL to the source code of the project.'),
/**
* Optional logo image URL to use for the project. Logos should have a square aspect ratio.
*/
@ -72,6 +65,15 @@ export const agenticProjectConfigSchema = z.object({
'Optional logo image URL to use for the project. Logos should have a square aspect ratio.'
),
/** Optional URL to the source code of the project. */
sourceUrl: z
.string()
.url()
.optional()
.describe(
'Optional URL to the source code of the project (eg, GitHub repo).'
),
/** Required origin API HTTPS base URL */
originUrl: z.string().url()
.describe(`Required base URL of the externally hosted origin API server. Must be a valid \`https\` URL.
@ -97,7 +99,8 @@ NOTE: Agentic currently only supports \`external\` API servers. If you'd like to
*
* Defaults to a single monthly interval `['month']`.
*
* To add support for annual pricing plans, you can use `['month', 'year']`.
* To add support for annual pricing plans, for example, you can use:
* `['month', 'year']`.
*
* Note that for every pricing interval, you must define a corresponding set
* of PricingPlans in the `pricingPlans` array. If you only have one pricing
@ -106,11 +109,14 @@ NOTE: Agentic currently only supports \`external\` API servers. If you'd like to
* specify their `interval` property to differentiate between different
* pricing intervals.
*/
pricingIntervals: z
.array(pricingIntervalSchema)
.nonempty({
message: 'Must contain at least one pricing interval'
})
pricingIntervals: pricingIntervalListSchema
.describe(
`Optional list of billing intervals to enable in the pricingPlans.
Defaults to a single monthly interval \`['month']\`.
To add support for annual pricing plans, for example, you can use: \`['month', 'year']\`.`
)
.optional()
.default(['month'])
})

Wyświetl plik

@ -1,5 +1,6 @@
export * from './agentic-project-config-schema'
export * from './define-config'
export * from './schemas'
export * from './utils'
export * from './validate-agentic-project-config'
export * from './validate-origin-adapter'

Wyświetl plik

@ -18,19 +18,33 @@ export const rateLimitSchema = z
* The interval at which the rate limit is applied.
*
* Either a number in seconds or a valid [ms](https://github.com/vercel/ms)
* string (eg, `10s`, `1m`, `1h`, `1d`, `1w`, `1y`, etc).
* string (eg, "10s", "1m", "1h", "1d", "1w", "1y", etc).
*/
interval: z.union([
z.number().nonnegative(), // seconds
z
.string()
.nonempty()
.transform((value, ctx) => {
try {
// TODO: `ms` module has broken types
const ms = parseIntervalAsMs(value as any) as unknown as number
interval: z
.union([
z.number().nonnegative(), // seconds
if (typeof ms !== 'number' || ms < 0) {
z
.string()
.nonempty()
.transform((value, ctx) => {
try {
// TODO: `ms` module has broken types
const ms = parseIntervalAsMs(value as any) as unknown as number
if (typeof ms !== 'number' || ms < 0) {
ctx.addIssue({
code: z.ZodIssueCode.custom,
message: `Invalid interval "${value}"`,
path: ctx.path
})
return z.NEVER
}
const seconds = Math.floor(ms / 1000)
return seconds
} catch {
ctx.addIssue({
code: z.ZodIssueCode.custom,
message: `Invalid interval "${value}"`,
@ -39,20 +53,11 @@ export const rateLimitSchema = z
return z.NEVER
}
const seconds = Math.floor(ms / 1000)
return seconds
} catch {
ctx.addIssue({
code: z.ZodIssueCode.custom,
message: `Invalid interval "${value}"`,
path: ctx.path
})
return z.NEVER
}
})
]),
})
])
.describe(
`The interval at which the rate limit is applied. Either a number in seconds or a valid [ms](https://github.com/vercel/ms) string (eg, "10s", "1m", "1h", "1d", "1w", "1y", etc).`
),
/**
* Maximum number of operations per interval (unitless).
@ -99,6 +104,17 @@ export const pricingIntervalSchema = z
.openapi('PricingInterval')
export type PricingInterval = z.infer<typeof pricingIntervalSchema>
/**
* List of billing intervals for subscriptions.
*/
export const pricingIntervalListSchema = z
.array(pricingIntervalSchema)
.nonempty({
message: 'Must contain at least one pricing interval'
})
.describe('List of billing intervals for subscriptions.')
.openapi('PricingIntervalList')
/**
* Internal PricingPlanLineItem hash
*

Wyświetl plik

@ -0,0 +1,28 @@
import type { PricingInterval, PricingPlan, PricingPlanList } from './schemas'
export function getPricingPlansByInterval({
pricingInterval,
pricingPlans
}: {
pricingInterval: PricingInterval
pricingPlans: PricingPlanList
}): PricingPlan[] {
return pricingPlans.filter(
(pricingPlan) =>
pricingPlan.interval === pricingInterval ||
pricingPlan.interval === undefined
)
}
const pricingIntervalToLabelMap: Record<PricingInterval, string> = {
day: 'daily',
week: 'weekly',
month: 'monthly',
year: 'yearly'
}
export function getLabelForPricingInterval(
pricingInterval: PricingInterval
): string {
return pricingIntervalToLabelMap[pricingInterval]
}

Wyświetl plik

@ -8,6 +8,7 @@ import {
type AgenticProjectConfigInput,
agenticProjectConfigSchema
} from './agentic-project-config-schema'
import { getPricingPlansByInterval } from './utils'
import { validateOriginAdapter } from './validate-origin-adapter'
export async function validateAgenticProjectConfig(
@ -199,6 +200,21 @@ export async function validateAgenticProjectConfig(
}
}
// Validate deployment pricing plans to ensure they contain at least one valid
// plan per pricing interval configured on the project.
for (const pricingInterval of config.pricingIntervals) {
const pricingPlansForInterval = getPricingPlansByInterval({
pricingInterval,
pricingPlans
})
assert(
pricingPlansForInterval.length > 0,
400,
`Invalid pricing config: no pricing plans for pricing interval "${pricingInterval}"`
)
}
await validateOriginAdapter({
...opts,
label: `project "${name}"`,

Wyświetl plik

@ -7,17 +7,24 @@
## TODO
- end-to-end working examples
- raw
- openapi
- mcp
- stripe
- re-add coupons
- declarative json-based pricing
- like https://github.com/tierrun/tier and Saasify
- https://github.com/tierrun/tier/blob/main/pricing/schema.json
- https://blog.tier.run/tier-hello-world-demo
- stripe connect
- consider switching to [consola](https://github.com/unjs/consola) for logging?
- consider switching to `bun` (for `--hot` reloading!!)
- transactional emails
- openauth password emails and `sendCode`
- re-add support for teams / organizations
- api gateway
- signed requests
## License