From ee5783e7804b488837651339cd23917faba2d645 Mon Sep 17 00:00:00 2001 From: Travis Fischer Date: Tue, 17 Jun 2025 09:21:22 +0700 Subject: [PATCH] feat: add private/public to projects and expose public projects for marketplace --- apps/api/src/api-v1/auth/github-callback.ts | 2 +- apps/api/src/api-v1/auth/github-exchange.ts | 2 +- apps/api/src/api-v1/auth/github-init.ts | 2 +- .../src/api-v1/auth/sign-in-with-password.ts | 4 +- .../src/api-v1/auth/sign-up-with-password.ts | 4 +- .../consumers/admin-activate-consumer.ts | 2 +- .../consumers/admin-get-consumer-by-token.ts | 2 +- .../src/api-v1/consumers/create-consumer.ts | 2 +- apps/api/src/api-v1/consumers/get-consumer.ts | 4 +- .../src/api-v1/consumers/list-consumers.ts | 2 +- .../consumers/list-project-consumers.ts | 2 +- .../consumers/refresh-consumer-token.ts | 2 +- .../src/api-v1/consumers/update-consumer.ts | 2 +- .../admin-get-deployment-by-identifier.ts | 2 +- .../api-v1/deployments/create-deployment.ts | 18 +- .../get-deployment-by-identifier.ts | 2 +- .../src/api-v1/deployments/get-deployment.ts | 2 +- .../api-v1/deployments/list-deployments.ts | 2 +- .../api-v1/deployments/publish-deployment.ts | 2 +- .../api-v1/deployments/update-deployment.ts | 2 +- apps/api/src/api-v1/index.ts | 150 +++++++------ .../api/src/api-v1/projects/create-project.ts | 11 +- .../projects/get-project-by-identifier.ts | 2 +- apps/api/src/api-v1/projects/get-project.ts | 4 +- .../get-public-project-by-identifier.ts | 60 +++++ .../src/api-v1/projects/get-public-project.ts | 59 +++++ apps/api/src/api-v1/projects/list-projects.ts | 6 +- .../api-v1/projects/list-public-projects.ts | 66 ++++++ apps/api/src/api-v1/projects/schemas.ts | 16 ++ .../api/src/api-v1/projects/update-project.ts | 2 +- apps/api/src/api-v1/teams/create-team.ts | 4 +- apps/api/src/api-v1/teams/delete-team.ts | 4 +- apps/api/src/api-v1/teams/get-team.ts | 2 +- apps/api/src/api-v1/teams/list-teams.ts | 4 +- .../teams/members/create-team-member.ts | 2 +- .../teams/members/delete-team-member.ts | 2 +- .../teams/members/update-team-member.ts | 2 +- apps/api/src/api-v1/teams/update-team.ts | 4 +- apps/api/src/api-v1/users/get-user.ts | 2 +- apps/api/src/api-v1/users/update-user.ts | 4 +- apps/api/src/db/schema/project.ts | 22 +- apps/e2e/bin/seed-db.ts | 2 +- .../src/app/marketplace/marketplace-index.tsx | 116 ++++++++++ apps/web/src/app/marketplace/page.tsx | 5 + .../marketplace-project-index.tsx | 58 +++++ .../[namespace]/[project-name]/page.tsx | 34 +++ packages/api-client/src/agentic-api-client.ts | 82 ++++++- packages/types/src/openapi.d.ts | 205 +++++++++++++++--- readme.md | 2 + 49 files changed, 809 insertions(+), 185 deletions(-) create mode 100644 apps/api/src/api-v1/projects/get-public-project-by-identifier.ts create mode 100644 apps/api/src/api-v1/projects/get-public-project.ts create mode 100644 apps/api/src/api-v1/projects/list-public-projects.ts create mode 100644 apps/web/src/app/marketplace/marketplace-index.tsx create mode 100644 apps/web/src/app/marketplace/page.tsx create mode 100644 apps/web/src/app/marketplace/projects/[namespace]/[project-name]/marketplace-project-index.tsx create mode 100644 apps/web/src/app/marketplace/projects/[namespace]/[project-name]/page.tsx diff --git a/apps/api/src/api-v1/auth/github-callback.ts b/apps/api/src/api-v1/auth/github-callback.ts index 5509da20..99ff94f4 100644 --- a/apps/api/src/api-v1/auth/github-callback.ts +++ b/apps/api/src/api-v1/auth/github-callback.ts @@ -4,7 +4,7 @@ import { assert } from '@agentic/platform-core' import { authStorage } from './utils' -export function registerV1AuthGitHubOAuthCallback( +export function registerV1GitHubOAuthCallback( app: OpenAPIHono ) { return app.get('auth/github/callback', async (c) => { diff --git a/apps/api/src/api-v1/auth/github-exchange.ts b/apps/api/src/api-v1/auth/github-exchange.ts index 67016dde..e88edac6 100644 --- a/apps/api/src/api-v1/auth/github-exchange.ts +++ b/apps/api/src/api-v1/auth/github-exchange.ts @@ -51,7 +51,7 @@ const route = createRoute({ } }) -export function registerV1AuthGitHubOAuthExchange( +export function registerV1GitHubOAuthExchange( app: OpenAPIHono ) { return app.openapi(route, async (c) => { diff --git a/apps/api/src/api-v1/auth/github-init.ts b/apps/api/src/api-v1/auth/github-init.ts index 30ca56d1..7933984d 100644 --- a/apps/api/src/api-v1/auth/github-init.ts +++ b/apps/api/src/api-v1/auth/github-init.ts @@ -35,7 +35,7 @@ const route = createRoute({ } }) -export function registerV1AuthGitHubOAuthInitFlow( +export function registerV1GitHubOAuthInitFlow( app: OpenAPIHono ) { return app.openapi(route, async (c) => { diff --git a/apps/api/src/api-v1/auth/sign-in-with-password.ts b/apps/api/src/api-v1/auth/sign-in-with-password.ts index 3dc21584..2675e920 100644 --- a/apps/api/src/api-v1/auth/sign-in-with-password.ts +++ b/apps/api/src/api-v1/auth/sign-in-with-password.ts @@ -49,9 +49,7 @@ const route = createRoute({ } }) -export function registerV1AuthSignInWithPassword( - app: OpenAPIHono -) { +export function registerV1SignInWithPassword(app: OpenAPIHono) { return app.openapi(route, trySignIn) } diff --git a/apps/api/src/api-v1/auth/sign-up-with-password.ts b/apps/api/src/api-v1/auth/sign-up-with-password.ts index c216d47b..3dcbbc10 100644 --- a/apps/api/src/api-v1/auth/sign-up-with-password.ts +++ b/apps/api/src/api-v1/auth/sign-up-with-password.ts @@ -52,9 +52,7 @@ const route = createRoute({ } }) -export function registerV1AuthSignUpWithPassword( - app: OpenAPIHono -) { +export function registerV1SignUpWithPassword(app: OpenAPIHono) { return app.openapi(route, async (c) => { try { // try signing in to see if the user already exists diff --git a/apps/api/src/api-v1/consumers/admin-activate-consumer.ts b/apps/api/src/api-v1/consumers/admin-activate-consumer.ts index 75732359..53d60ba2 100644 --- a/apps/api/src/api-v1/consumers/admin-activate-consumer.ts +++ b/apps/api/src/api-v1/consumers/admin-activate-consumer.ts @@ -42,7 +42,7 @@ const route = createRoute({ } }) -export function registerV1AdminConsumersActivateConsumer( +export function registerV1AdminActivateConsumer( app: OpenAPIHono ) { return app.openapi(route, async (c) => { diff --git a/apps/api/src/api-v1/consumers/admin-get-consumer-by-token.ts b/apps/api/src/api-v1/consumers/admin-get-consumer-by-token.ts index 56f3a89a..ee8bf763 100644 --- a/apps/api/src/api-v1/consumers/admin-get-consumer-by-token.ts +++ b/apps/api/src/api-v1/consumers/admin-get-consumer-by-token.ts @@ -38,7 +38,7 @@ const route = createRoute({ } }) -export function registerV1AdminConsumersGetConsumerByToken( +export function registerV1AdminGetConsumerByToken( app: OpenAPIHono ) { return app.openapi(route, async (c) => { diff --git a/apps/api/src/api-v1/consumers/create-consumer.ts b/apps/api/src/api-v1/consumers/create-consumer.ts index b65fb104..54d8c535 100644 --- a/apps/api/src/api-v1/consumers/create-consumer.ts +++ b/apps/api/src/api-v1/consumers/create-consumer.ts @@ -45,7 +45,7 @@ const route = createRoute({ } }) -export function registerV1ConsumersCreateConsumer( +export function registerV1CreateConsumer( app: OpenAPIHono ) { return app.openapi(route, async (c) => { diff --git a/apps/api/src/api-v1/consumers/get-consumer.ts b/apps/api/src/api-v1/consumers/get-consumer.ts index d124d4e5..d799956d 100644 --- a/apps/api/src/api-v1/consumers/get-consumer.ts +++ b/apps/api/src/api-v1/consumers/get-consumer.ts @@ -37,9 +37,7 @@ const route = createRoute({ } }) -export function registerV1ConsumersGetConsumer( - app: OpenAPIHono -) { +export function registerV1GetConsumer(app: OpenAPIHono) { return app.openapi(route, async (c) => { const { consumerId } = c.req.valid('param') const { populate = [] } = c.req.valid('query') diff --git a/apps/api/src/api-v1/consumers/list-consumers.ts b/apps/api/src/api-v1/consumers/list-consumers.ts index eb5f8c54..092efffa 100644 --- a/apps/api/src/api-v1/consumers/list-consumers.ts +++ b/apps/api/src/api-v1/consumers/list-consumers.ts @@ -36,7 +36,7 @@ const route = createRoute({ } }) -export function registerV1ConsumersListConsumers( +export function registerV1ListConsumers( app: OpenAPIHono ) { return app.openapi(route, async (c) => { diff --git a/apps/api/src/api-v1/consumers/list-project-consumers.ts b/apps/api/src/api-v1/consumers/list-project-consumers.ts index f9a0f854..1b3bef47 100644 --- a/apps/api/src/api-v1/consumers/list-project-consumers.ts +++ b/apps/api/src/api-v1/consumers/list-project-consumers.ts @@ -38,7 +38,7 @@ const route = createRoute({ } }) -export function registerV1ConsumersListForProject( +export function registerV1ListConsumersForProject( app: OpenAPIHono ) { return app.openapi(route, async (c) => { diff --git a/apps/api/src/api-v1/consumers/refresh-consumer-token.ts b/apps/api/src/api-v1/consumers/refresh-consumer-token.ts index 77f4e170..f5eb66a3 100644 --- a/apps/api/src/api-v1/consumers/refresh-consumer-token.ts +++ b/apps/api/src/api-v1/consumers/refresh-consumer-token.ts @@ -37,7 +37,7 @@ const route = createRoute({ } }) -export function registerV1ConsumersRefreshConsumerToken( +export function registerV1RefreshConsumerToken( app: OpenAPIHono ) { return app.openapi(route, async (c) => { diff --git a/apps/api/src/api-v1/consumers/update-consumer.ts b/apps/api/src/api-v1/consumers/update-consumer.ts index 62c65963..9a11e49f 100644 --- a/apps/api/src/api-v1/consumers/update-consumer.ts +++ b/apps/api/src/api-v1/consumers/update-consumer.ts @@ -49,7 +49,7 @@ const route = createRoute({ } }) -export function registerV1ConsumersUpdateConsumer( +export function registerV1UpdateConsumer( app: OpenAPIHono ) { return app.openapi(route, async (c) => { 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 b5041152..f1936597 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 @@ -39,7 +39,7 @@ const route = createRoute({ } }) -export function registerV1AdminDeploymentsGetDeploymentByIdentifier( +export function registerV1AdminGetDeploymentByIdentifier( app: OpenAPIHono ) { return app.openapi(route, async (c) => { diff --git a/apps/api/src/api-v1/deployments/create-deployment.ts b/apps/api/src/api-v1/deployments/create-deployment.ts index 935e9f4f..03cd69e1 100644 --- a/apps/api/src/api-v1/deployments/create-deployment.ts +++ b/apps/api/src/api-v1/deployments/create-deployment.ts @@ -2,7 +2,7 @@ import { resolveAgenticProjectConfig } from '@agentic/platform' import { assert, parseZodSchema, sha256 } from '@agentic/platform-core' import { isValidDeploymentIdentifier, - isValidProjectIdentifier + parseProjectIdentifier } from '@agentic/platform-validators' import { createRoute, type OpenAPIHono } from '@hono/zod-openapi' @@ -54,7 +54,7 @@ const route = createRoute({ } }) -export function registerV1DeploymentsCreateDeployment( +export function registerV1CreateDeployment( app: OpenAPIHono ) { return app.openapi(route, async (c) => { @@ -64,13 +64,10 @@ export function registerV1DeploymentsCreateDeployment( const teamMember = c.get('teamMember') const logger = c.get('logger') - const namespace = teamMember ? teamMember.teamSlug : user.username - const projectIdentifier = `@${namespace}/${body.name}` - assert( - isValidProjectIdentifier(projectIdentifier), - 400, - `Invalid project identifier "${projectIdentifier}"` - ) + const inputNamespace = teamMember ? teamMember.teamSlug : user.username + const inputProjectIdentifier = `@${inputNamespace}/${body.name}` + const { projectIdentifier, projectNamespace, projectName } = + parseProjectIdentifier(inputProjectIdentifier) let project = await db.query.projects.findFirst({ where: eq(schema.projects.identifier, projectIdentifier), @@ -88,8 +85,9 @@ export function registerV1DeploymentsCreateDeployment( await db .insert(schema.projects) .values({ - name: body.name, identifier: projectIdentifier, + namespace: projectNamespace, + name: projectName, userId: user.id, teamId: teamMember?.teamId, _secret: await sha256() diff --git a/apps/api/src/api-v1/deployments/get-deployment-by-identifier.ts b/apps/api/src/api-v1/deployments/get-deployment-by-identifier.ts index 9885a86f..bf56e39d 100644 --- a/apps/api/src/api-v1/deployments/get-deployment-by-identifier.ts +++ b/apps/api/src/api-v1/deployments/get-deployment-by-identifier.ts @@ -37,7 +37,7 @@ const route = createRoute({ } }) -export function registerV1DeploymentsGetDeploymentByIdentifier( +export function registerV1GetDeploymentByIdentifier( app: OpenAPIHono ) { return app.openapi(route, async (c) => { diff --git a/apps/api/src/api-v1/deployments/get-deployment.ts b/apps/api/src/api-v1/deployments/get-deployment.ts index 7ee63a05..7899c71c 100644 --- a/apps/api/src/api-v1/deployments/get-deployment.ts +++ b/apps/api/src/api-v1/deployments/get-deployment.ts @@ -38,7 +38,7 @@ const route = createRoute({ } }) -export function registerV1DeploymentsGetDeployment( +export function registerV1GetDeployment( app: OpenAPIHono ) { return app.openapi(route, async (c) => { diff --git a/apps/api/src/api-v1/deployments/list-deployments.ts b/apps/api/src/api-v1/deployments/list-deployments.ts index c93c8038..b5868fb9 100644 --- a/apps/api/src/api-v1/deployments/list-deployments.ts +++ b/apps/api/src/api-v1/deployments/list-deployments.ts @@ -37,7 +37,7 @@ const route = createRoute({ } }) -export function registerV1DeploymentsListDeployments( +export function registerV1ListDeployments( app: OpenAPIHono ) { return app.openapi(route, async (c) => { diff --git a/apps/api/src/api-v1/deployments/publish-deployment.ts b/apps/api/src/api-v1/deployments/publish-deployment.ts index 062ef548..173e86dd 100644 --- a/apps/api/src/api-v1/deployments/publish-deployment.ts +++ b/apps/api/src/api-v1/deployments/publish-deployment.ts @@ -46,7 +46,7 @@ const route = createRoute({ } }) -export function registerV1DeploymentsPublishDeployment( +export function registerV1PublishDeployment( app: OpenAPIHono ) { return app.openapi(route, async (c) => { diff --git a/apps/api/src/api-v1/deployments/update-deployment.ts b/apps/api/src/api-v1/deployments/update-deployment.ts index 39325592..dfc67079 100644 --- a/apps/api/src/api-v1/deployments/update-deployment.ts +++ b/apps/api/src/api-v1/deployments/update-deployment.ts @@ -45,7 +45,7 @@ const route = createRoute({ } }) -export function registerV1DeploymentsUpdateDeployment( +export function registerV1UpdateDeployment( app: OpenAPIHono ) { return app.openapi(route, async (c) => { diff --git a/apps/api/src/api-v1/index.ts b/apps/api/src/api-v1/index.ts index fa4aa663..36901085 100644 --- a/apps/api/src/api-v1/index.ts +++ b/apps/api/src/api-v1/index.ts @@ -6,42 +6,45 @@ import type { AuthenticatedHonoEnv } from '@/lib/types' import * as middleware from '@/lib/middleware' import { registerOpenAPIErrorResponses } from '@/lib/openapi-utils' -import { registerV1AuthGitHubOAuthCallback } from './auth/github-callback' -import { registerV1AuthGitHubOAuthExchange } from './auth/github-exchange' -import { registerV1AuthGitHubOAuthInitFlow } from './auth/github-init' -import { registerV1AuthSignInWithPassword } from './auth/sign-in-with-password' -import { registerV1AuthSignUpWithPassword } from './auth/sign-up-with-password' -import { registerV1AdminConsumersActivateConsumer } from './consumers/admin-activate-consumer' -import { registerV1AdminConsumersGetConsumerByToken } from './consumers/admin-get-consumer-by-token' -import { registerV1ConsumersCreateConsumer } from './consumers/create-consumer' -import { registerV1ConsumersGetConsumer } from './consumers/get-consumer' -import { registerV1ConsumersListConsumers } from './consumers/list-consumers' -import { registerV1ConsumersListForProject } from './consumers/list-project-consumers' -import { registerV1ConsumersRefreshConsumerToken } from './consumers/refresh-consumer-token' -import { registerV1ConsumersUpdateConsumer } from './consumers/update-consumer' -import { registerV1AdminDeploymentsGetDeploymentByIdentifier } from './deployments/admin-get-deployment-by-identifier' -import { registerV1DeploymentsCreateDeployment } from './deployments/create-deployment' -import { registerV1DeploymentsGetDeployment } from './deployments/get-deployment' -import { registerV1DeploymentsGetDeploymentByIdentifier } from './deployments/get-deployment-by-identifier' -import { registerV1DeploymentsListDeployments } from './deployments/list-deployments' -import { registerV1DeploymentsPublishDeployment } from './deployments/publish-deployment' -import { registerV1DeploymentsUpdateDeployment } from './deployments/update-deployment' +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 { registerV1AdminGetConsumerByToken } from './consumers/admin-get-consumer-by-token' +import { registerV1CreateConsumer } from './consumers/create-consumer' +import { registerV1GetConsumer } from './consumers/get-consumer' +import { registerV1ListConsumers } from './consumers/list-consumers' +import { registerV1ListConsumersForProject } from './consumers/list-project-consumers' +import { registerV1RefreshConsumerToken } from './consumers/refresh-consumer-token' +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 { registerV1ListDeployments } from './deployments/list-deployments' +import { registerV1PublishDeployment } from './deployments/publish-deployment' +import { registerV1UpdateDeployment } from './deployments/update-deployment' import { registerHealthCheck } from './health-check' -import { registerV1ProjectsCreateProject } from './projects/create-project' -import { registerV1ProjectsGetProject } from './projects/get-project' -import { registerV1ProjectsGetProjectByIdentifier } from './projects/get-project-by-identifier' -import { registerV1ProjectsListProjects } from './projects/list-projects' -import { registerV1ProjectsUpdateProject } from './projects/update-project' -import { registerV1TeamsCreateTeam } from './teams/create-team' -import { registerV1TeamsDeleteTeam } from './teams/delete-team' -import { registerV1TeamsGetTeam } from './teams/get-team' -import { registerV1TeamsListTeams } from './teams/list-teams' -import { registerV1TeamsMembersCreateTeamMember } from './teams/members/create-team-member' -import { registerV1TeamsMembersDeleteTeamMember } from './teams/members/delete-team-member' -import { registerV1TeamsMembersUpdateTeamMember } from './teams/members/update-team-member' -import { registerV1TeamsUpdateTeam } from './teams/update-team' -import { registerV1UsersGetUser } from './users/get-user' -import { registerV1UsersUpdateUser } from './users/update-user' +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 { 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 @@ -79,55 +82,60 @@ const privateRouter = new OpenAPIHono() registerHealthCheck(publicRouter) // Auth -registerV1AuthSignInWithPassword(publicRouter) -registerV1AuthSignUpWithPassword(publicRouter) -registerV1AuthGitHubOAuthExchange(publicRouter) -registerV1AuthGitHubOAuthInitFlow(publicRouter) -registerV1AuthGitHubOAuthCallback(publicRouter) +registerV1SignInWithPassword(publicRouter) +registerV1SignUpWithPassword(publicRouter) +registerV1GitHubOAuthExchange(publicRouter) +registerV1GitHubOAuthInitFlow(publicRouter) +registerV1GitHubOAuthCallback(publicRouter) // Users -registerV1UsersGetUser(privateRouter) -registerV1UsersUpdateUser(privateRouter) +registerV1GetUser(privateRouter) +registerV1UpdateUser(privateRouter) // Teams -registerV1TeamsCreateTeam(privateRouter) -registerV1TeamsListTeams(privateRouter) -registerV1TeamsGetTeam(privateRouter) -registerV1TeamsDeleteTeam(privateRouter) -registerV1TeamsUpdateTeam(privateRouter) +registerV1CreateTeam(privateRouter) +registerV1ListTeams(privateRouter) +registerV1GetTeam(privateRouter) +registerV1DeleteTeam(privateRouter) +registerV1UpdateTeam(privateRouter) // Team members -registerV1TeamsMembersCreateTeamMember(privateRouter) -registerV1TeamsMembersUpdateTeamMember(privateRouter) -registerV1TeamsMembersDeleteTeamMember(privateRouter) +registerV1CreateTeamMember(privateRouter) +registerV1UpdateTeamMember(privateRouter) +registerV1DeleteTeamMember(privateRouter) -// Projects -registerV1ProjectsCreateProject(privateRouter) -registerV1ProjectsListProjects(privateRouter) -registerV1ProjectsGetProjectByIdentifier(privateRouter) // must be before `registerV1ProjectsGetProject` -registerV1ProjectsGetProject(privateRouter) -registerV1ProjectsUpdateProject(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 -registerV1ConsumersGetConsumer(privateRouter) -registerV1ConsumersCreateConsumer(privateRouter) -registerV1ConsumersUpdateConsumer(privateRouter) -registerV1ConsumersRefreshConsumerToken(privateRouter) -registerV1ConsumersListConsumers(privateRouter) -registerV1ConsumersListForProject(privateRouter) +registerV1GetConsumer(privateRouter) +registerV1CreateConsumer(privateRouter) +registerV1UpdateConsumer(privateRouter) +registerV1RefreshConsumerToken(privateRouter) +registerV1ListConsumers(privateRouter) +registerV1ListConsumersForProject(privateRouter) // Deployments -registerV1DeploymentsGetDeploymentByIdentifier(privateRouter) // must be before `registerV1DeploymentsGetDeployment` -registerV1DeploymentsGetDeployment(privateRouter) -registerV1DeploymentsCreateDeployment(privateRouter) -registerV1DeploymentsUpdateDeployment(privateRouter) -registerV1DeploymentsListDeployments(privateRouter) -registerV1DeploymentsPublishDeployment(privateRouter) +registerV1GetDeploymentByIdentifier(privateRouter) // must be before `registerV1GetDeployment` +registerV1GetDeployment(privateRouter) +registerV1CreateDeployment(privateRouter) +registerV1UpdateDeployment(privateRouter) +registerV1ListDeployments(privateRouter) +registerV1PublishDeployment(privateRouter) // Internal admin routes -registerV1AdminConsumersGetConsumerByToken(privateRouter) -registerV1AdminConsumersActivateConsumer(privateRouter) -registerV1AdminDeploymentsGetDeploymentByIdentifier(privateRouter) +registerV1AdminGetConsumerByToken(privateRouter) +registerV1AdminActivateConsumer(privateRouter) +registerV1AdminGetDeploymentByIdentifier(privateRouter) // Webhook event handlers registerV1StripeWebhook(publicRouter) diff --git a/apps/api/src/api-v1/projects/create-project.ts b/apps/api/src/api-v1/projects/create-project.ts index 8a783e8a..afb8d1c1 100644 --- a/apps/api/src/api-v1/projects/create-project.ts +++ b/apps/api/src/api-v1/projects/create-project.ts @@ -1,5 +1,5 @@ import { assert, parseZodSchema, sha256 } from '@agentic/platform-core' -import { isValidProjectIdentifier } from '@agentic/platform-validators' +import { parseProjectIdentifier } from '@agentic/platform-validators' import { createRoute, type OpenAPIHono } from '@hono/zod-openapi' import type { AuthenticatedHonoEnv } from '@/lib/types' @@ -40,7 +40,7 @@ const route = createRoute({ } }) -export function registerV1ProjectsCreateProject( +export function registerV1CreateProject( app: OpenAPIHono ) { return app.openapi(route, async (c) => { @@ -54,8 +54,9 @@ export function registerV1ProjectsCreateProject( const teamMember = c.get('teamMember') const namespace = teamMember ? teamMember.teamSlug : user.username const identifier = `@${namespace}/${body.name}` + const parsedProjectIdentifier = parseProjectIdentifier(identifier) assert( - isValidProjectIdentifier(identifier), + parsedProjectIdentifier, 400, `Invalid project identifier "${identifier}"` ) @@ -64,7 +65,9 @@ export function registerV1ProjectsCreateProject( .insert(schema.projects) .values({ ...body, - identifier, + identifier: parsedProjectIdentifier.projectIdentifier, + namespace: parsedProjectIdentifier.projectNamespace, + name: parsedProjectIdentifier.projectName, teamId: teamMember?.teamId, userId: user.id, _secret: await sha256() diff --git a/apps/api/src/api-v1/projects/get-project-by-identifier.ts b/apps/api/src/api-v1/projects/get-project-by-identifier.ts index 0ff694a7..3ad3c320 100644 --- a/apps/api/src/api-v1/projects/get-project-by-identifier.ts +++ b/apps/api/src/api-v1/projects/get-project-by-identifier.ts @@ -37,7 +37,7 @@ const route = createRoute({ } }) -export function registerV1ProjectsGetProjectByIdentifier( +export function registerV1GetProjectByIdentifier( app: OpenAPIHono ) { return app.openapi(route, async (c) => { diff --git a/apps/api/src/api-v1/projects/get-project.ts b/apps/api/src/api-v1/projects/get-project.ts index a30c9187..40418730 100644 --- a/apps/api/src/api-v1/projects/get-project.ts +++ b/apps/api/src/api-v1/projects/get-project.ts @@ -37,9 +37,7 @@ const route = createRoute({ } }) -export function registerV1ProjectsGetProject( - app: OpenAPIHono -) { +export function registerV1GetProject(app: OpenAPIHono) { return app.openapi(route, async (c) => { const { projectId } = c.req.valid('param') const { populate = [] } = c.req.valid('query') diff --git a/apps/api/src/api-v1/projects/get-public-project-by-identifier.ts b/apps/api/src/api-v1/projects/get-public-project-by-identifier.ts new file mode 100644 index 00000000..a1a49e41 --- /dev/null +++ b/apps/api/src/api-v1/projects/get-public-project-by-identifier.ts @@ -0,0 +1,60 @@ +import type { DefaultHonoEnv } from '@agentic/platform-hono' +import { assert, parseZodSchema } from '@agentic/platform-core' +import { createRoute, type OpenAPIHono } from '@hono/zod-openapi' + +import { db, eq, schema } from '@/db' +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-name").', + 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])) + } + }) + assert( + project && project.private && project.lastPublishedDeploymentId, + 404, + `Public project not found "${projectIdentifier}"` + ) + + return c.json(parseZodSchema(schema.projectSelectSchema, project)) + }) +} diff --git a/apps/api/src/api-v1/projects/get-public-project.ts b/apps/api/src/api-v1/projects/get-public-project.ts new file mode 100644 index 00000000..ca2403ed --- /dev/null +++ b/apps/api/src/api-v1/projects/get-public-project.ts @@ -0,0 +1,59 @@ +import type { DefaultHonoEnv } from '@agentic/platform-hono' +import { assert, parseZodSchema } from '@agentic/platform-core' +import { createRoute, type OpenAPIHono } from '@hono/zod-openapi' + +import { db, eq, schema } from '@/db' +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])) + } + }) + assert( + project && project.private && project.lastPublishedDeploymentId, + 404, + `Public project not found "${projectId}"` + ) + + return c.json(parseZodSchema(schema.projectSelectSchema, project)) + }) +} diff --git a/apps/api/src/api-v1/projects/list-projects.ts b/apps/api/src/api-v1/projects/list-projects.ts index 76a281cc..c042768d 100644 --- a/apps/api/src/api-v1/projects/list-projects.ts +++ b/apps/api/src/api-v1/projects/list-projects.ts @@ -12,7 +12,7 @@ import { import { paginationAndPopulateProjectSchema } from './schemas' const route = createRoute({ - description: 'Lists projects the authenticated user has access to.', + description: 'Lists projects owned by the authenticated user or team.', tags: ['projects'], operationId: 'listProjects', method: 'get', @@ -34,9 +34,7 @@ const route = createRoute({ } }) -export function registerV1ProjectsListProjects( - app: OpenAPIHono -) { +export function registerV1ListProjects(app: OpenAPIHono) { return app.openapi(route, async (c) => { const { offset = 0, diff --git a/apps/api/src/api-v1/projects/list-public-projects.ts b/apps/api/src/api-v1/projects/list-public-projects.ts new file mode 100644 index 00000000..91c23e18 --- /dev/null +++ b/apps/api/src/api-v1/projects/list-public-projects.ts @@ -0,0 +1,66 @@ +import type { DefaultHonoEnv } from '@agentic/platform-hono' +import { parseZodSchema } from '@agentic/platform-core' +import { createRoute, type OpenAPIHono, z } from '@hono/zod-openapi' + +import { and, db, eq, isNotNull, schema } from '@/db' +import { + openapiAuthenticatedSecuritySchemas, + openapiErrorResponses +} from '@/lib/openapi-utils' + +import { paginationAndPopulateProjectSchema } 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: paginationAndPopulateProjectSchema + }, + 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 = [] + } = c.req.valid('query') + + const projects = await db.query.projects.findMany({ + // List projects that are not private and have at least one published deployment + where: and( + eq(schema.projects.private, false), + isNotNull(schema.projects.lastPublishedDeploymentId) + ), + 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)) + }) +} diff --git a/apps/api/src/api-v1/projects/schemas.ts b/apps/api/src/api-v1/projects/schemas.ts index f455c9a4..a3049d03 100644 --- a/apps/api/src/api-v1/projects/schemas.ts +++ b/apps/api/src/api-v1/projects/schemas.ts @@ -1,3 +1,4 @@ +// import { isValidNamespace } from '@agentic/platform-validators' import { z } from '@hono/zod-openapi' import { @@ -17,6 +18,21 @@ export const projectIdParamsSchema = z.object({ }) }) +// 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 }) diff --git a/apps/api/src/api-v1/projects/update-project.ts b/apps/api/src/api-v1/projects/update-project.ts index b0cfbfbd..54726fca 100644 --- a/apps/api/src/api-v1/projects/update-project.ts +++ b/apps/api/src/api-v1/projects/update-project.ts @@ -44,7 +44,7 @@ const route = createRoute({ } }) -export function registerV1ProjectsUpdateProject( +export function registerV1UpdateProject( app: OpenAPIHono ) { return app.openapi(route, async (c) => { diff --git a/apps/api/src/api-v1/teams/create-team.ts b/apps/api/src/api-v1/teams/create-team.ts index 7b0ef5ae..b213cf8e 100644 --- a/apps/api/src/api-v1/teams/create-team.ts +++ b/apps/api/src/api-v1/teams/create-team.ts @@ -40,9 +40,7 @@ const route = createRoute({ } }) -export function registerV1TeamsCreateTeam( - app: OpenAPIHono -) { +export function registerV1CreateTeam(app: OpenAPIHono) { return app.openapi(route, async (c) => { const user = await ensureAuthUser(c) const body = c.req.valid('json') diff --git a/apps/api/src/api-v1/teams/delete-team.ts b/apps/api/src/api-v1/teams/delete-team.ts index c4f5a4d9..5dd91eeb 100644 --- a/apps/api/src/api-v1/teams/delete-team.ts +++ b/apps/api/src/api-v1/teams/delete-team.ts @@ -36,9 +36,7 @@ const route = createRoute({ } }) -export function registerV1TeamsDeleteTeam( - app: OpenAPIHono -) { +export function registerV1DeleteTeam(app: OpenAPIHono) { return app.openapi(route, async (c) => { const { teamId } = c.req.valid('param') await aclTeamAdmin(c, { teamId }) diff --git a/apps/api/src/api-v1/teams/get-team.ts b/apps/api/src/api-v1/teams/get-team.ts index daf73c7d..a9e6885b 100644 --- a/apps/api/src/api-v1/teams/get-team.ts +++ b/apps/api/src/api-v1/teams/get-team.ts @@ -36,7 +36,7 @@ const route = createRoute({ } }) -export function registerV1TeamsGetTeam(app: OpenAPIHono) { +export function registerV1GetTeam(app: OpenAPIHono) { return app.openapi(route, async (c) => { const { teamId } = c.req.valid('param') await aclTeamMember(c, { teamId }) diff --git a/apps/api/src/api-v1/teams/list-teams.ts b/apps/api/src/api-v1/teams/list-teams.ts index aa66822f..44353e02 100644 --- a/apps/api/src/api-v1/teams/list-teams.ts +++ b/apps/api/src/api-v1/teams/list-teams.ts @@ -31,9 +31,7 @@ const route = createRoute({ } }) -export function registerV1TeamsListTeams( - app: OpenAPIHono -) { +export function registerV1ListTeams(app: OpenAPIHono) { return app.openapi(route, async (c) => { const { offset = 0, diff --git a/apps/api/src/api-v1/teams/members/create-team-member.ts b/apps/api/src/api-v1/teams/members/create-team-member.ts index 2d94dbd8..f6935eeb 100644 --- a/apps/api/src/api-v1/teams/members/create-team-member.ts +++ b/apps/api/src/api-v1/teams/members/create-team-member.ts @@ -46,7 +46,7 @@ const route = createRoute({ } }) -export function registerV1TeamsMembersCreateTeamMember( +export function registerV1CreateTeamMember( app: OpenAPIHono ) { return app.openapi(route, async (c) => { diff --git a/apps/api/src/api-v1/teams/members/delete-team-member.ts b/apps/api/src/api-v1/teams/members/delete-team-member.ts index e4933d96..6b7d6fde 100644 --- a/apps/api/src/api-v1/teams/members/delete-team-member.ts +++ b/apps/api/src/api-v1/teams/members/delete-team-member.ts @@ -37,7 +37,7 @@ const route = createRoute({ } }) -export function registerV1TeamsMembersDeleteTeamMember( +export function registerV1DeleteTeamMember( app: OpenAPIHono ) { return app.openapi(route, async (c) => { diff --git a/apps/api/src/api-v1/teams/members/update-team-member.ts b/apps/api/src/api-v1/teams/members/update-team-member.ts index 7c20b731..3891d436 100644 --- a/apps/api/src/api-v1/teams/members/update-team-member.ts +++ b/apps/api/src/api-v1/teams/members/update-team-member.ts @@ -45,7 +45,7 @@ const route = createRoute({ } }) -export function registerV1TeamsMembersUpdateTeamMember( +export function registerV1UpdateTeamMember( app: OpenAPIHono ) { return app.openapi(route, async (c) => { diff --git a/apps/api/src/api-v1/teams/update-team.ts b/apps/api/src/api-v1/teams/update-team.ts index 179afa9d..e14aac22 100644 --- a/apps/api/src/api-v1/teams/update-team.ts +++ b/apps/api/src/api-v1/teams/update-team.ts @@ -44,9 +44,7 @@ const route = createRoute({ } }) -export function registerV1TeamsUpdateTeam( - app: OpenAPIHono -) { +export function registerV1UpdateTeam(app: OpenAPIHono) { return app.openapi(route, async (c) => { const { teamId } = c.req.valid('param') const body = c.req.valid('json') diff --git a/apps/api/src/api-v1/users/get-user.ts b/apps/api/src/api-v1/users/get-user.ts index 27af74dc..1c447868 100644 --- a/apps/api/src/api-v1/users/get-user.ts +++ b/apps/api/src/api-v1/users/get-user.ts @@ -36,7 +36,7 @@ const route = createRoute({ } }) -export function registerV1UsersGetUser(app: OpenAPIHono) { +export function registerV1GetUser(app: OpenAPIHono) { return app.openapi(route, async (c) => { const { userId } = c.req.valid('param') await acl(c, { userId }, { label: 'User' }) diff --git a/apps/api/src/api-v1/users/update-user.ts b/apps/api/src/api-v1/users/update-user.ts index 798b6956..977dca7b 100644 --- a/apps/api/src/api-v1/users/update-user.ts +++ b/apps/api/src/api-v1/users/update-user.ts @@ -44,9 +44,7 @@ const route = createRoute({ } }) -export function registerV1UsersUpdateUser( - app: OpenAPIHono -) { +export function registerV1UpdateUser(app: OpenAPIHono) { return app.openapi(route, async (c) => { const { userId } = c.req.valid('param') await acl(c, { userId }, { label: 'User' }) diff --git a/apps/api/src/db/schema/project.ts b/apps/api/src/db/schema/project.ts index 7cd5f65a..93685d5e 100644 --- a/apps/api/src/db/schema/project.ts +++ b/apps/api/src/db/schema/project.ts @@ -10,6 +10,7 @@ import { import { isValidProjectName } from '@agentic/platform-validators' import { relations } from '@fisch0920/drizzle-orm' import { + boolean, index, integer, jsonb, @@ -62,8 +63,16 @@ export const projects = pgTable( ...timestamps, identifier: projectIdentifier().unique().notNull(), + namespace: text().notNull(), name: text().notNull(), - alias: text(), + + // 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(), + + // TODO: allow for multiple aliases like vercel + // alias: text(), userId: userId() .notNull() @@ -144,9 +153,14 @@ export const projects = pgTable( }, (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_alias_idx').on(table.alias), + index('project_private_idx').on(table.private), + 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) @@ -250,8 +264,8 @@ export const projectInsertSchema = createInsertSchema(projects, { export const projectUpdateSchema = createUpdateSchema(projects) .pick({ - name: true, - alias: true + name: true + // alias: true }) .strict() diff --git a/apps/e2e/bin/seed-db.ts b/apps/e2e/bin/seed-db.ts index 1e136a8d..df6bfb0d 100644 --- a/apps/e2e/bin/seed-db.ts +++ b/apps/e2e/bin/seed-db.ts @@ -1,7 +1,7 @@ import { AgenticApiClient } from '@agentic/platform-api-client' -import { env } from '../src/env' import { deployFixtures } from '../src/deploy-fixtures' +import { env } from '../src/env' export const client = new AgenticApiClient({ apiBaseUrl: env.AGENTIC_API_BASE_URL diff --git a/apps/web/src/app/marketplace/marketplace-index.tsx b/apps/web/src/app/marketplace/marketplace-index.tsx new file mode 100644 index 00000000..9d58846b --- /dev/null +++ b/apps/web/src/app/marketplace/marketplace-index.tsx @@ -0,0 +1,116 @@ +'use client' + +import { useInfiniteQuery } from '@tanstack/react-query' +import Link from 'next/link' +import useInfiniteScroll from 'react-infinite-scroll-hook' + +import { useAgentic } from '@/components/agentic-provider' +import { LoadingIndicator } from '@/components/loading-indicator' +import { toastError } from '@/lib/notifications' + +export function MarketplaceIndex() { + const ctx = useAgentic() + const limit = 10 + const { + data, + isLoading, + isError, + hasNextPage, + fetchNextPage, + isFetchingNextPage + } = useInfiniteQuery({ + queryKey: ['projects'], + queryFn: ({ pageParam = 0 }) => + ctx!.api + .listPublicProjects({ + populate: ['lastPublishedDeployment'], + offset: pageParam, + limit + }) + .then(async (projects) => { + return { + projects, + offset: pageParam, + limit, + nextOffset: + projects.length >= limit ? pageParam + projects.length : undefined + } + }) + .catch((err: any) => { + void toastError('Failed to fetch projects') + throw err + }), + 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 ( + <> +
+

+ Dashboard +

+ + {!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 && )} +
+ )} +
+ )} +
+ )} +
+ + ) +} diff --git a/apps/web/src/app/marketplace/page.tsx b/apps/web/src/app/marketplace/page.tsx new file mode 100644 index 00000000..2ab8f809 --- /dev/null +++ b/apps/web/src/app/marketplace/page.tsx @@ -0,0 +1,5 @@ +import { MarketplaceIndex } from './marketplace-index' + +export default function MarketplaceIndexPage() { + return +} diff --git a/apps/web/src/app/marketplace/projects/[namespace]/[project-name]/marketplace-project-index.tsx b/apps/web/src/app/marketplace/projects/[namespace]/[project-name]/marketplace-project-index.tsx new file mode 100644 index 00000000..af574140 --- /dev/null +++ b/apps/web/src/app/marketplace/projects/[namespace]/[project-name]/marketplace-project-index.tsx @@ -0,0 +1,58 @@ +'use client' + +import { useQuery } from '@tanstack/react-query' + +import { useAgentic } from '@/components/agentic-provider' +import { LoadingIndicator } from '@/components/loading-indicator' +import { toastError } from '@/lib/notifications' + +export function MarketplaceProjectIndex({ + projectIdentifier +}: { + projectIdentifier: string +}) { + const ctx = useAgentic() + const { + data: project, + isLoading, + isError + } = useQuery({ + queryKey: ['project', projectIdentifier], + queryFn: () => + ctx!.api + .getPublicProjectByIdentifier({ + projectIdentifier, + populate: ['lastPublishedDeployment'] + }) + .catch((err: any) => { + void toastError(`Failed to fetch project "${projectIdentifier}"`) + throw err + }), + enabled: !!ctx + }) + + return ( +
+ {!ctx || isLoading ? ( + + ) : isError ? ( +

Error fetching project

+ ) : !project ? ( +

Project "{projectIdentifier}" not found

+ ) : ( + <> +

+ Project {project.name} +

+ +
+
{JSON.stringify(project, null, 2)}
+
+ + )} +
+ ) +} diff --git a/apps/web/src/app/marketplace/projects/[namespace]/[project-name]/page.tsx b/apps/web/src/app/marketplace/projects/[namespace]/[project-name]/page.tsx new file mode 100644 index 00000000..1b9fa9b9 --- /dev/null +++ b/apps/web/src/app/marketplace/projects/[namespace]/[project-name]/page.tsx @@ -0,0 +1,34 @@ +import { parseProjectIdentifier } from '@agentic/platform-validators' +import { notFound } from 'next/navigation' + +import { toastError } from '@/lib/notifications' + +import { MarketplaceProjectIndex } from './marketplace-project-index' + +export default async function MarketplaceProjectIndexPage({ + params +}: { + params: Promise<{ + namespace: string + 'project-name': string + }> +}) { + const { namespace: rawNamespace, 'project-name': rawProjectName } = + await params + + try { + const namespace = decodeURIComponent(rawNamespace) + const projectName = decodeURIComponent(rawProjectName) + + const { projectIdentifier } = parseProjectIdentifier( + `${namespace}/${projectName}`, + { strict: true } + ) + + return + } catch (err: any) { + void toastError(err, { label: 'Invalid project identifier' }) + + return notFound() + } +} diff --git a/packages/api-client/src/agentic-api-client.ts b/packages/api-client/src/agentic-api-client.ts index 6b711f8e..c3aef0f7 100644 --- a/packages/api-client/src/agentic-api-client.ts +++ b/packages/api-client/src/agentic-api-client.ts @@ -230,9 +230,10 @@ export class AgenticApiClient { /** Gets the currently authenticated user. */ async getMe(): Promise { // const user = await this.verifyAuthAndRefreshIfNecessary() - assert(this._authSession) + const userId = this._authSession?.user.id + assert(userId, 'This method requires authentication.') - return this.ky.get(`v1/users/${this._authSession.user.id}`).json() + return this.ky.get(`v1/users/${userId}`).json() } /** Gets a user by ID. */ @@ -335,7 +336,65 @@ export class AgenticApiClient { .json() } - /** Lists projects the authenticated user has access to. */ + /** Lists projects that have been published publicly to the marketplace. */ + async listPublicProjects< + TPopulate extends NonNullable< + OperationParameters<'listPublicProjects'>['populate'] + >[number] + >( + searchParams: OperationParameters<'listPublicProjects'> & { + populate?: TPopulate[] + } = {} + ): Promise>> { + return this.ky + .get('v1/projects', { searchParams: sanitizeSearchParams(searchParams) }) + .json() + } + + /** + * Gets a public project by ID. The project must be publicly available on + * the marketplace. + */ + async getPublicProject< + TPopulate extends NonNullable< + OperationParameters<'getPublicProject'>['populate'] + >[number] + >({ + projectId, + ...searchParams + }: OperationParameters<'getPublicProject'> & { + populate?: TPopulate[] + }): Promise> { + return this.ky + .get(`v1/projects/public/${projectId}`, { + searchParams: sanitizeSearchParams(searchParams) + }) + .json() + } + + /** + * Gets a public project by its identifier. The project must be publicly + * available on the marketplace. + */ + async getPublicProjectByIdentifier< + TPopulate extends NonNullable< + OperationParameters<'getPublicProjectByIdentifier'>['populate'] + >[number] + >( + searchParams: OperationParameters<'getPublicProjectByIdentifier'> & { + populate?: TPopulate[] + } + ): Promise> { + return this.ky + .get('v1/projects/public/by-identifier', { + searchParams: sanitizeSearchParams(searchParams) + }) + .json() + } + + /** + * Lists projects the authenticated user has access to. + */ async listProjects< TPopulate extends NonNullable< OperationParameters<'listProjects'>['populate'] @@ -346,7 +405,9 @@ export class AgenticApiClient { } = {} ): Promise>> { return this.ky - .get('v1/projects', { searchParams: sanitizeSearchParams(searchParams) }) + .get(`v1/projects`, { + searchParams: sanitizeSearchParams(searchParams) + }) .json() } @@ -358,7 +419,9 @@ export class AgenticApiClient { return this.ky.post('v1/projects', { json: project, searchParams }).json() } - /** Gets a project by ID. */ + /** + * Gets a project by ID. Authenticated user must have access to the project. + */ async getProject< TPopulate extends NonNullable< OperationParameters<'getProject'>['populate'] @@ -376,7 +439,10 @@ export class AgenticApiClient { .json() } - /** Gets a project by its public identifier. */ + /** + * Gets a project by its identifier. Authenticated user must have access to + * the project. + */ async getProjectByIdentifier< TPopulate extends NonNullable< OperationParameters<'getProjectByIdentifier'>['populate'] @@ -393,7 +459,9 @@ export class AgenticApiClient { .json() } - /** Updates a project. */ + /** + * Updates a project. Authenticated user must have access to the project. + */ async updateProject( project: OperationBody<'updateProject'>, { projectId, ...searchParams }: OperationParameters<'updateProject'> diff --git a/packages/types/src/openapi.d.ts b/packages/types/src/openapi.d.ts index 36a7def1..0ba075ba 100644 --- a/packages/types/src/openapi.d.ts +++ b/packages/types/src/openapi.d.ts @@ -89,6 +89,57 @@ export interface paths { patch?: never; trace?: never; }; + "/v1/projects/public": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + /** @description Lists projects that have been published publicly to the marketplace. */ + get: operations["listPublicProjects"]; + put?: never; + post?: never; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/v1/projects/public/by-identifier": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + /** @description Gets a public project by its public identifier (eg, "@username/project-name"). */ + get: operations["getPublicProjectByIdentifier"]; + put?: never; + post?: never; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/v1/projects/public/{projectId}": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + /** @description Gets a public project by ID. */ + get: operations["getPublicProject"]; + put?: never; + post?: never; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; "/v1/users/{userId}": { parameters: { query?: never; @@ -186,7 +237,7 @@ export interface paths { path?: never; cookie?: never; }; - /** @description Lists projects the authenticated user has access to. */ + /** @description Lists projects owned by the authenticated user or team. */ get: operations["listProjects"]; put?: never; /** @description Creates a new project. */ @@ -445,6 +496,39 @@ export interface components { token: string; user: components["schemas"]["User"]; }; + /** @description Public project identifier (e.g. "@namespace/project-name") */ + ProjectIdentifier: string; + /** @description The frequency at which a subscription is billed. */ + PricingInterval: "day" | "week" | "month" | "year"; + /** @description A Project represents a single Agentic API product. A Project 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). */ + Project: { + /** @description Project id (e.g. "proj_tz4a98xxat96iws9zmbrgj3a") */ + id: string; + createdAt: string; + updatedAt: string; + deletedAt?: string; + identifier: components["schemas"]["ProjectIdentifier"]; + namespace: string; + name: string; + private: boolean; + /** @description User id (e.g. "user_tz4a98xxat96iws9zmbrgj3a") */ + userId: string; + /** @description Team id (e.g. "team_tz4a98xxat96iws9zmbrgj3a") */ + teamId?: string; + /** @description Deployment id (e.g. "depl_tz4a98xxat96iws9zmbrgj3a") */ + lastPublishedDeploymentId?: string; + /** @description Deployment id (e.g. "depl_tz4a98xxat96iws9zmbrgj3a") */ + lastDeploymentId?: string; + lastPublishedDeploymentVersion?: string; + applicationFeePercent: number; + defaultPricingInterval: components["schemas"]["PricingInterval"]; + /** @enum {string} */ + pricingCurrency: "usd"; + }; Team: { id: string; createdAt: string; @@ -466,38 +550,6 @@ export interface components { confirmed: boolean; confirmedAt?: string; }; - /** @description Public project identifier (e.g. "@namespace/project-name") */ - ProjectIdentifier: string; - /** @description The frequency at which a subscription is billed. */ - PricingInterval: "day" | "week" | "month" | "year"; - /** @description A Project represents a single Agentic API product. A Project 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). */ - Project: { - /** @description Project id (e.g. "proj_tz4a98xxat96iws9zmbrgj3a") */ - id: string; - createdAt: string; - updatedAt: string; - deletedAt?: string; - identifier: components["schemas"]["ProjectIdentifier"]; - name: string; - alias?: string; - /** @description User id (e.g. "user_tz4a98xxat96iws9zmbrgj3a") */ - userId: string; - /** @description Team id (e.g. "team_tz4a98xxat96iws9zmbrgj3a") */ - teamId?: string; - /** @description Deployment id (e.g. "depl_tz4a98xxat96iws9zmbrgj3a") */ - lastPublishedDeploymentId?: string; - /** @description Deployment id (e.g. "depl_tz4a98xxat96iws9zmbrgj3a") */ - lastDeploymentId?: string; - lastPublishedDeploymentVersion?: string; - applicationFeePercent: number; - defaultPricingInterval: components["schemas"]["PricingInterval"]; - /** @enum {string} */ - pricingCurrency: "usd"; - }; /** @description A Consumer represents a user who has subscribed to a Project and is used * to track usage and billing. * @@ -1065,6 +1117,92 @@ export interface operations { 404: components["responses"]["404"]; }; }; + listPublicProjects: { + parameters: { + query?: { + offset?: number | null; + limit?: number; + sort?: "asc" | "desc"; + sortBy?: "createdAt" | "updatedAt"; + populate?: ("user" | "team" | "lastPublishedDeployment" | "lastDeployment") | ("user" | "team" | "lastPublishedDeployment" | "lastDeployment")[]; + }; + header?: never; + path?: never; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description A list of projects */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["Project"][]; + }; + }; + 400: components["responses"]["400"]; + 401: components["responses"]["401"]; + 403: components["responses"]["403"]; + }; + }; + getPublicProjectByIdentifier: { + parameters: { + query: { + populate?: ("user" | "team" | "lastPublishedDeployment" | "lastDeployment") | ("user" | "team" | "lastPublishedDeployment" | "lastDeployment")[]; + /** @description Public project identifier (e.g. "@namespace/project-name") */ + projectIdentifier: components["schemas"]["ProjectIdentifier"]; + }; + header?: never; + path?: never; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description A project */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["Project"]; + }; + }; + 400: components["responses"]["400"]; + 401: components["responses"]["401"]; + 403: components["responses"]["403"]; + 404: components["responses"]["404"]; + }; + }; + getPublicProject: { + parameters: { + query?: { + populate?: ("user" | "team" | "lastPublishedDeployment" | "lastDeployment") | ("user" | "team" | "lastPublishedDeployment" | "lastDeployment")[]; + }; + header?: never; + path: { + /** @description Project ID */ + projectId: string; + }; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description A project */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["Project"]; + }; + }; + 400: components["responses"]["400"]; + 401: components["responses"]["401"]; + 403: components["responses"]["403"]; + 404: components["responses"]["404"]; + }; + }; getUser: { parameters: { query?: never; @@ -1505,7 +1643,6 @@ export interface operations { content: { "application/json": { name?: string; - alias?: string; }; }; }; diff --git a/readme.md b/readme.md index 62082092..df4068cd 100644 --- a/readme.md +++ b/readme.md @@ -30,6 +30,8 @@ - hosted docs - merge with current agentic repo - publish packages to npm +- api + - deploy to prod - database - consider using [neon serverless driver](https://orm.drizzle.team/docs/connect-neon) for production - can this also be used locally?