feat(den-api): add marketplaces for plugin grouping (#1484)

Introduce org-scoped marketplaces so teams can curate and share groups of plugins with consistent access rules. This adds the schema, admin routes, RBAC updates, and PRD coverage needed for marketplace-backed plugin catalogs.

Co-authored-by: src-opn <src-opn@users.noreply.github.com>
This commit is contained in:
Source Open
2026-04-17 20:42:43 -07:00
committed by GitHub
parent 0002a8e030
commit 58ae294191
15 changed files with 1175 additions and 12 deletions

View File

@@ -4,6 +4,9 @@ import {
ConfigObjectTable,
ConnectorInstanceAccessGrantTable,
ConnectorInstanceTable,
MarketplaceAccessGrantTable,
MarketplacePluginTable,
MarketplaceTable,
PluginAccessGrantTable,
PluginConfigObjectTable,
PluginTable,
@@ -12,9 +15,9 @@ import type { MemberTeamSummary, OrganizationContext } from "../../../orgs.js"
import { db } from "../../../db.js"
import { memberHasRole } from "../shared.js"
export type PluginArchResourceKind = "config_object" | "connector_instance" | "plugin"
export type PluginArchResourceKind = "config_object" | "connector_instance" | "marketplace" | "plugin"
export type PluginArchRole = "viewer" | "editor" | "manager"
export type PluginArchCapability = "config_object.create" | "connector_account.create" | "connector_instance.create" | "plugin.create"
export type PluginArchCapability = "config_object.create" | "connector_account.create" | "connector_instance.create" | "marketplace.create" | "plugin.create"
export type PluginArchActorContext = {
memberTeams: MemberTeamSummary[]
@@ -24,12 +27,20 @@ export type PluginArchActorContext = {
type MemberId = OrganizationContext["currentMember"]["id"]
type TeamId = MemberTeamSummary["id"]
type ConfigObjectId = typeof ConfigObjectTable.$inferSelect.id
type MarketplaceId = typeof MarketplaceTable.$inferSelect.id
type PluginId = typeof PluginTable.$inferSelect.id
type ConnectorInstanceId = typeof ConnectorInstanceTable.$inferSelect.id
type ConfigObjectGrantRow = Pick<typeof ConfigObjectAccessGrantTable.$inferSelect, "orgMembershipId" | "orgWide" | "removedAt" | "role" | "teamId">
type MarketplaceGrantRow = Pick<typeof MarketplaceAccessGrantTable.$inferSelect, "orgMembershipId" | "orgWide" | "removedAt" | "role" | "teamId">
type PluginGrantRow = Pick<typeof PluginAccessGrantTable.$inferSelect, "orgMembershipId" | "orgWide" | "removedAt" | "role" | "teamId">
type ConnectorInstanceGrantRow = Pick<typeof ConnectorInstanceAccessGrantTable.$inferSelect, "orgMembershipId" | "orgWide" | "removedAt" | "role" | "teamId">
type GrantRow = ConfigObjectGrantRow | PluginGrantRow | ConnectorInstanceGrantRow
type GrantRow = ConfigObjectGrantRow | MarketplaceGrantRow | PluginGrantRow | ConnectorInstanceGrantRow
type MarketplaceResourceLookupInput = {
context: PluginArchActorContext
resourceId: MarketplaceId
resourceKind: "marketplace"
}
type PluginResourceLookupInput = {
context: PluginArchActorContext
@@ -52,6 +63,7 @@ type ConfigObjectResourceLookupInput = {
type ResourceLookupInput =
| PluginResourceLookupInput
| ConnectorInstanceResourceLookupInput
| MarketplaceResourceLookupInput
| ConfigObjectResourceLookupInput
type RequireResourceRoleInput = ResourceLookupInput & { role: PluginArchRole }
@@ -144,11 +156,48 @@ async function resolvePluginRoleForIds(context: PluginArchActorContext, pluginId
return resolveGrantRole({ context, grants })
}
async function resolveMarketplaceRoleForIds(context: PluginArchActorContext, marketplaceIds: MarketplaceId[]) {
if (marketplaceIds.length === 0) {
return null
}
if (isPluginArchOrgAdmin(context)) {
return "manager" satisfies PluginArchRole
}
const grants = await db
.select({
orgMembershipId: MarketplaceAccessGrantTable.orgMembershipId,
orgWide: MarketplaceAccessGrantTable.orgWide,
removedAt: MarketplaceAccessGrantTable.removedAt,
role: MarketplaceAccessGrantTable.role,
teamId: MarketplaceAccessGrantTable.teamId,
})
.from(MarketplaceAccessGrantTable)
.where(inArray(MarketplaceAccessGrantTable.marketplaceId, marketplaceIds))
return resolveGrantRole({ context, grants })
}
export async function resolvePluginArchResourceRole(input: ResourceLookupInput) {
if (isPluginArchOrgAdmin(input.context)) {
return "manager" satisfies PluginArchRole
}
if (input.resourceKind === "marketplace") {
const grants = await db
.select({
orgMembershipId: MarketplaceAccessGrantTable.orgMembershipId,
orgWide: MarketplaceAccessGrantTable.orgWide,
removedAt: MarketplaceAccessGrantTable.removedAt,
role: MarketplaceAccessGrantTable.role,
teamId: MarketplaceAccessGrantTable.teamId,
})
.from(MarketplaceAccessGrantTable)
.where(eq(MarketplaceAccessGrantTable.marketplaceId, input.resourceId))
return resolveGrantRole({ context: input.context, grants })
}
if (input.resourceKind === "plugin") {
const grants = await db
.select({
@@ -160,7 +209,18 @@ export async function resolvePluginArchResourceRole(input: ResourceLookupInput)
})
.from(PluginAccessGrantTable)
.where(eq(PluginAccessGrantTable.pluginId, input.resourceId))
return resolveGrantRole({ context: input.context, grants })
const resolved = await resolveGrantRole({ context: input.context, grants })
if (resolved) {
return resolved
}
const memberships = await db
.select({ marketplaceId: MarketplacePluginTable.marketplaceId })
.from(MarketplacePluginTable)
.where(and(eq(MarketplacePluginTable.pluginId, input.resourceId), isNull(MarketplacePluginTable.removedAt)))
const marketplaceRole = await resolveMarketplaceRoleForIds(input.context, memberships.map((membership) => membership.marketplaceId))
return maxRole(resolved, marketplaceRole ? "viewer" : null)
}
if (input.resourceKind === "connector_instance") {
@@ -213,7 +273,7 @@ export async function requirePluginArchCapability(context: PluginArchActorContex
export async function requirePluginArchResourceRole(input: {
context: PluginArchActorContext
resourceId: ConfigObjectId | ConnectorInstanceId | PluginId
resourceId: ConfigObjectId | ConnectorInstanceId | MarketplaceId | PluginId
resourceKind: PluginArchResourceKind
role: PluginArchRole
}) {

View File

@@ -61,6 +61,18 @@ import {
githubWebhookIgnoredResponseSchema,
githubWebhookRawBodySchema,
githubWebhookUnauthorizedResponseSchema,
marketplaceAccessGrantParamsSchema,
marketplaceCreateSchema,
marketplaceDetailResponseSchema,
marketplaceListQuerySchema,
marketplaceListResponseSchema,
marketplaceMutationResponseSchema,
marketplaceParamsSchema,
marketplacePluginListResponseSchema,
marketplacePluginMutationResponseSchema,
marketplacePluginParamsSchema,
marketplacePluginWriteSchema,
marketplaceUpdateSchema,
pluginAccessGrantParamsSchema,
pluginConfigObjectParamsSchema,
pluginCreateSchema,
@@ -79,7 +91,7 @@ import { orgIdParamSchema } from "../shared.js"
type EndpointMethod = "DELETE" | "GET" | "PATCH" | "POST"
type EndpointAudience = "admin" | "public_webhook"
type EndpointTag = "Config Objects" | "Plugins" | "Connectors" | "GitHub" | "Webhooks"
type EndpointTag = "Config Objects" | "Plugins" | "Marketplaces" | "Connectors" | "GitHub" | "Webhooks"
type EndpointContract = {
audience: EndpointAudience
@@ -139,6 +151,14 @@ export const pluginArchRoutePaths = {
pluginReleases: `${orgBasePath}/plugins/:pluginId/releases`,
pluginAccess: `${orgBasePath}/plugins/:pluginId/access`,
pluginAccessGrant: `${orgBasePath}/plugins/:pluginId/access/:grantId`,
marketplaces: `${orgBasePath}/marketplaces`,
marketplace: `${orgBasePath}/marketplaces/:marketplaceId`,
marketplaceArchive: `${orgBasePath}/marketplaces/:marketplaceId/archive`,
marketplaceRestore: `${orgBasePath}/marketplaces/:marketplaceId/restore`,
marketplacePlugins: `${orgBasePath}/marketplaces/:marketplaceId/plugins`,
marketplacePlugin: `${orgBasePath}/marketplaces/:marketplaceId/plugins/:pluginId`,
marketplaceAccess: `${orgBasePath}/marketplaces/:marketplaceId/access`,
marketplaceAccessGrant: `${orgBasePath}/marketplaces/:marketplaceId/access/:grantId`,
connectorAccounts: `${orgBasePath}/connector-accounts`,
connectorAccount: `${orgBasePath}/connector-accounts/:connectorAccountId`,
connectorAccountDisconnect: `${orgBasePath}/connector-accounts/:connectorAccountId/disconnect`,
@@ -427,6 +447,114 @@ export const pluginArchEndpointContracts: Record<string, EndpointContract> = {
response: { description: "Plugin access grant revoked successfully.", status: 204 },
tag: "Plugins",
},
listMarketplaces: {
audience: "admin",
description: "List accessible marketplaces for the organization.",
method: "GET",
path: pluginArchRoutePaths.marketplaces,
request: { params: orgIdParamSchema, query: marketplaceListQuerySchema },
response: { description: "Marketplace list.", schema: marketplaceListResponseSchema, status: 200 },
tag: "Marketplaces",
},
getMarketplace: {
audience: "admin",
description: "Get one marketplace and its current metadata.",
method: "GET",
path: pluginArchRoutePaths.marketplace,
request: { params: marketplaceParamsSchema },
response: { description: "Marketplace detail.", schema: marketplaceDetailResponseSchema, status: 200 },
tag: "Marketplaces",
},
createMarketplace: {
audience: "admin",
description: "Create a private-by-default marketplace.",
method: "POST",
path: pluginArchRoutePaths.marketplaces,
request: { body: marketplaceCreateSchema, params: orgIdParamSchema },
response: { description: "Marketplace created successfully.", schema: marketplaceMutationResponseSchema, status: 201 },
tag: "Marketplaces",
},
updateMarketplace: {
audience: "admin",
description: "Patch marketplace metadata.",
method: "PATCH",
path: pluginArchRoutePaths.marketplace,
request: { body: marketplaceUpdateSchema, params: marketplaceParamsSchema },
response: { description: "Marketplace updated successfully.", schema: marketplaceMutationResponseSchema, status: 200 },
tag: "Marketplaces",
},
archiveMarketplace: {
audience: "admin",
description: "Archive a marketplace without deleting membership history.",
method: "POST",
path: pluginArchRoutePaths.marketplaceArchive,
request: { params: marketplaceParamsSchema },
response: { description: "Archived marketplace detail.", schema: marketplaceMutationResponseSchema, status: 200 },
tag: "Marketplaces",
},
restoreMarketplace: {
audience: "admin",
description: "Restore an archived or deleted marketplace.",
method: "POST",
path: pluginArchRoutePaths.marketplaceRestore,
request: { params: marketplaceParamsSchema },
response: { description: "Restored marketplace detail.", schema: marketplaceMutationResponseSchema, status: 200 },
tag: "Marketplaces",
},
listMarketplacePlugins: {
audience: "admin",
description: "List marketplace memberships and the plugins they reference.",
method: "GET",
path: pluginArchRoutePaths.marketplacePlugins,
request: { params: marketplaceParamsSchema },
response: { description: "Marketplace plugin memberships.", schema: marketplacePluginListResponseSchema, status: 200 },
tag: "Marketplaces",
},
addMarketplacePlugin: {
audience: "admin",
description: "Add a plugin to a marketplace using marketplace-scoped write access.",
method: "POST",
path: pluginArchRoutePaths.marketplacePlugins,
request: { body: marketplacePluginWriteSchema, params: marketplaceParamsSchema },
response: { description: "Marketplace plugin membership created successfully.", schema: marketplacePluginMutationResponseSchema, status: 201 },
tag: "Marketplaces",
},
removeMarketplacePlugin: {
audience: "admin",
description: "Remove one plugin membership from a marketplace.",
method: "DELETE",
path: pluginArchRoutePaths.marketplacePlugin,
request: { params: marketplacePluginParamsSchema },
response: { description: "Marketplace plugin membership removed successfully.", status: 204 },
tag: "Marketplaces",
},
listMarketplaceAccess: {
audience: "admin",
description: "List direct, team, and org-wide grants for a marketplace.",
method: "GET",
path: pluginArchRoutePaths.marketplaceAccess,
request: { params: marketplaceParamsSchema },
response: { description: "Marketplace access grants.", schema: accessGrantListResponseSchema, status: 200 },
tag: "Marketplaces",
},
grantMarketplaceAccess: {
audience: "admin",
description: "Create one direct, team, or org-wide access grant for a marketplace.",
method: "POST",
path: pluginArchRoutePaths.marketplaceAccess,
request: { body: resourceAccessGrantWriteSchema, params: marketplaceParamsSchema },
response: { description: "Marketplace access grant created successfully.", schema: accessGrantMutationResponseSchema, status: 201 },
tag: "Marketplaces",
},
revokeMarketplaceAccess: {
audience: "admin",
description: "Soft-revoke one marketplace access grant.",
method: "DELETE",
path: pluginArchRoutePaths.marketplaceAccessGrant,
request: { params: marketplaceAccessGrantParamsSchema },
response: { description: "Marketplace access grant revoked successfully.", status: 204 },
tag: "Marketplaces",
},
listConnectorAccounts: {
audience: "admin",
description: "List connector accounts such as GitHub App installations available to the org.",

View File

@@ -60,6 +60,18 @@ import {
githubConnectorSetupSchema,
githubValidateTargetResponseSchema,
githubValidateTargetSchema,
marketplaceAccessGrantParamsSchema,
marketplaceCreateSchema,
marketplaceDetailResponseSchema,
marketplaceListQuerySchema,
marketplaceListResponseSchema,
marketplaceMutationResponseSchema,
marketplaceParamsSchema,
marketplacePluginListResponseSchema,
marketplacePluginMutationResponseSchema,
marketplacePluginParamsSchema,
marketplacePluginWriteSchema,
marketplaceUpdateSchema,
pluginAccessGrantParamsSchema,
pluginCreateSchema,
pluginDetailResponseSchema,
@@ -85,6 +97,7 @@ import {
createConnectorInstance,
createConnectorMapping,
createGithubConnectorAccount,
createMarketplace,
createPlugin,
createResourceAccessGrant,
createConnectorTarget,
@@ -98,6 +111,7 @@ import {
getConnectorSyncEventDetail,
getConnectorTargetDetail,
getLatestConfigObjectVersion,
getMarketplaceDetail,
getPluginDetail,
githubSetup,
listConfigObjectPlugins,
@@ -109,19 +123,25 @@ import {
listConnectorSyncEvents,
listConnectorTargets,
listGithubRepositories,
listMarketplaceMemberships,
listMarketplaces,
listPluginMemberships,
listPlugins,
listResourceAccess,
attachPluginToMarketplace,
queueConnectorTargetResync,
removeConfigObjectFromPlugin,
removePluginFromMarketplace,
removePluginMembership,
retryConnectorSyncEvent,
setConfigObjectLifecycle,
setConnectorInstanceLifecycle,
setMarketplaceLifecycle,
setPluginLifecycle,
updateConnectorInstance,
updateConnectorMapping,
updateConnectorTarget,
updateMarketplace,
updatePlugin,
validateGithubTarget,
} from "./store.js"
@@ -807,6 +827,263 @@ export function registerPluginArchRoutes<T extends { Variables: OrgRouteVariable
}
})
withPluginArchOrgContext(app, "get", pluginArchRoutePaths.marketplaces,
paramValidator(marketplaceParamsSchema.pick({ orgId: true })),
queryValidator(marketplaceListQuerySchema),
describeRoute({
tags: ["Marketplaces"],
summary: "List marketplaces",
description: "Lists marketplaces visible to the current organization member.",
responses: {
200: jsonResponse("Marketplaces returned successfully.", marketplaceListResponseSchema),
400: jsonResponse("The marketplace query parameters were invalid.", invalidRequestSchema),
401: jsonResponse("The caller must be signed in to list marketplaces.", unauthorizedSchema),
},
}),
async (c: OrgContext) => {
const query = validQuery<any>(c)
return c.json(await listMarketplaces({ context: actorContext(c), cursor: query.cursor, limit: query.limit, q: query.q, status: query.status }))
})
withPluginArchOrgContext(app, "post", pluginArchRoutePaths.marketplaces,
paramValidator(marketplaceParamsSchema.pick({ orgId: true })),
jsonValidator(marketplaceCreateSchema),
describeRoute({
tags: ["Marketplaces"],
summary: "Create marketplace",
description: "Creates a new private marketplace and grants the creator manager access.",
responses: {
201: jsonResponse("Marketplace created successfully.", marketplaceMutationResponseSchema),
400: jsonResponse("The marketplace creation request was invalid.", invalidRequestSchema),
401: jsonResponse("The caller must be signed in to create marketplaces.", unauthorizedSchema),
403: jsonResponse("The caller lacks permission to create marketplaces.", forbiddenSchema),
},
}),
async (c: OrgContext) => {
try {
const context = actorContext(c)
await requirePluginArchCapability(context, "marketplace.create")
const body = validJson<any>(c)
return c.json({ ok: true, item: await createMarketplace({ context, description: body.description, name: body.name }) }, 201)
} catch (error) {
return routeErrorResponse(c, error)
}
})
withPluginArchOrgContext(app, "get", pluginArchRoutePaths.marketplace,
paramValidator(marketplaceParamsSchema),
describeRoute({
tags: ["Marketplaces"],
summary: "Get marketplace",
description: "Returns one marketplace detail when the caller can view it.",
responses: {
200: jsonResponse("Marketplace returned successfully.", marketplaceDetailResponseSchema),
400: jsonResponse("The marketplace path parameters were invalid.", invalidRequestSchema),
401: jsonResponse("The caller must be signed in to view marketplaces.", unauthorizedSchema),
404: jsonResponse("The marketplace could not be found.", notFoundSchema),
},
}),
async (c: OrgContext) => {
try {
const params = validParam<any>(c)
return c.json({ item: await getMarketplaceDetail(actorContext(c), params.marketplaceId) })
} catch (error) {
return routeErrorResponse(c, error)
}
})
withPluginArchOrgContext(app, "patch", pluginArchRoutePaths.marketplace,
paramValidator(marketplaceParamsSchema),
jsonValidator(marketplaceUpdateSchema),
describeRoute({
tags: ["Marketplaces"],
summary: "Update marketplace",
description: "Updates marketplace metadata.",
responses: {
200: jsonResponse("Marketplace updated successfully.", marketplaceMutationResponseSchema),
400: jsonResponse("The marketplace update request was invalid.", invalidRequestSchema),
401: jsonResponse("The caller must be signed in to update marketplaces.", unauthorizedSchema),
403: jsonResponse("The caller lacks permission to edit this marketplace.", forbiddenSchema),
404: jsonResponse("The marketplace could not be found.", notFoundSchema),
},
}),
async (c: OrgContext) => {
try {
const params = validParam<any>(c)
const body = validJson<any>(c)
return c.json({ ok: true, item: await updateMarketplace({ context: actorContext(c), description: body.description, marketplaceId: params.marketplaceId, name: body.name }) })
} catch (error) {
return routeErrorResponse(c, error)
}
})
for (const [path, action] of [[pluginArchRoutePaths.marketplaceArchive, "archive"], [pluginArchRoutePaths.marketplaceRestore, "restore"]] as const) {
withPluginArchOrgContext(app, "post", path,
paramValidator(marketplaceParamsSchema),
describeRoute({
tags: ["Marketplaces"],
summary: `${action} marketplace`,
description: `${action} a marketplace without touching membership history.`,
responses: {
200: jsonResponse("Marketplace lifecycle updated successfully.", marketplaceMutationResponseSchema),
400: jsonResponse("The marketplace lifecycle path parameters were invalid.", invalidRequestSchema),
401: jsonResponse("The caller must be signed in to manage marketplaces.", unauthorizedSchema),
403: jsonResponse("The caller lacks permission to manage this marketplace.", forbiddenSchema),
404: jsonResponse("The marketplace could not be found.", notFoundSchema),
},
}),
async (c: OrgContext) => {
try {
const params = validParam<any>(c)
return c.json({ ok: true, item: await setMarketplaceLifecycle({ action, context: actorContext(c), marketplaceId: params.marketplaceId }) })
} catch (error) {
return routeErrorResponse(c, error)
}
})
}
withPluginArchOrgContext(app, "get", pluginArchRoutePaths.marketplacePlugins,
paramValidator(marketplaceParamsSchema),
describeRoute({
tags: ["Marketplaces"],
summary: "List marketplace plugins",
description: "Lists marketplace memberships and resolved plugin projections.",
responses: {
200: jsonResponse("Marketplace memberships returned successfully.", marketplacePluginListResponseSchema),
400: jsonResponse("The marketplace membership path parameters were invalid.", invalidRequestSchema),
401: jsonResponse("The caller must be signed in to view marketplace memberships.", unauthorizedSchema),
404: jsonResponse("The marketplace could not be found.", notFoundSchema),
},
}),
async (c: OrgContext) => {
try {
const params = validParam<any>(c)
return c.json(await listMarketplaceMemberships({ context: actorContext(c), includePlugins: true, marketplaceId: params.marketplaceId, onlyActive: false }))
} catch (error) {
return routeErrorResponse(c, error)
}
})
withPluginArchOrgContext(app, "post", pluginArchRoutePaths.marketplacePlugins,
paramValidator(marketplaceParamsSchema),
jsonValidator(marketplacePluginWriteSchema),
describeRoute({
tags: ["Marketplaces"],
summary: "Add marketplace plugin",
description: "Adds a plugin to a marketplace.",
responses: {
201: jsonResponse("Marketplace membership created successfully.", marketplacePluginMutationResponseSchema),
400: jsonResponse("The marketplace membership request was invalid.", invalidRequestSchema),
401: jsonResponse("The caller must be signed in to manage marketplace memberships.", unauthorizedSchema),
403: jsonResponse("The caller lacks permission to edit this marketplace.", forbiddenSchema),
404: jsonResponse("The marketplace or plugin could not be found.", notFoundSchema),
},
}),
async (c: OrgContext) => {
try {
const params = validParam<any>(c)
const body = validJson<any>(c)
return c.json({ ok: true, item: await attachPluginToMarketplace({ context: actorContext(c), marketplaceId: params.marketplaceId, membershipSource: body.membershipSource, pluginId: body.pluginId }) }, 201)
} catch (error) {
return routeErrorResponse(c, error)
}
})
withPluginArchOrgContext(app, "delete", pluginArchRoutePaths.marketplacePlugin,
paramValidator(marketplacePluginParamsSchema),
describeRoute({
tags: ["Marketplaces"],
summary: "Remove marketplace plugin",
description: "Removes one plugin from a marketplace.",
responses: {
204: emptyResponse("Marketplace membership removed successfully."),
400: jsonResponse("The marketplace membership path parameters were invalid.", invalidRequestSchema),
401: jsonResponse("The caller must be signed in to manage marketplace memberships.", unauthorizedSchema),
403: jsonResponse("The caller lacks permission to edit this marketplace.", forbiddenSchema),
404: jsonResponse("The marketplace membership could not be found.", notFoundSchema),
},
}),
async (c: OrgContext) => {
try {
const params = validParam<any>(c)
await removePluginFromMarketplace({ context: actorContext(c), marketplaceId: params.marketplaceId, pluginId: params.pluginId })
return c.body(null, 204)
} catch (error) {
return routeErrorResponse(c, error)
}
})
withPluginArchOrgContext(app, "get", pluginArchRoutePaths.marketplaceAccess,
paramValidator(marketplaceParamsSchema),
describeRoute({
tags: ["Marketplaces"],
summary: "List marketplace access grants",
description: "Lists direct, team, and org-wide grants for a marketplace.",
responses: {
200: jsonResponse("Marketplace access grants returned successfully.", accessGrantListResponseSchema),
400: jsonResponse("The marketplace access path parameters were invalid.", invalidRequestSchema),
401: jsonResponse("The caller must be signed in to manage marketplace access.", unauthorizedSchema),
403: jsonResponse("The caller lacks permission to manage marketplace access.", forbiddenSchema),
404: jsonResponse("The marketplace could not be found.", notFoundSchema),
},
}),
async (c: OrgContext) => {
try {
const params = validParam<any>(c)
return c.json(await listResourceAccess({ context: actorContext(c), resourceId: params.marketplaceId, resourceKind: "marketplace" }))
} catch (error) {
return routeErrorResponse(c, error)
}
})
withPluginArchOrgContext(app, "post", pluginArchRoutePaths.marketplaceAccess,
paramValidator(marketplaceParamsSchema),
jsonValidator(resourceAccessGrantWriteSchema),
describeRoute({
tags: ["Marketplaces"],
summary: "Grant marketplace access",
description: "Creates or reactivates one access grant for a marketplace.",
responses: {
201: jsonResponse("Marketplace access grant created successfully.", accessGrantMutationResponseSchema),
400: jsonResponse("The marketplace access request was invalid.", invalidRequestSchema),
401: jsonResponse("The caller must be signed in to manage marketplace access.", unauthorizedSchema),
403: jsonResponse("The caller lacks permission to manage marketplace access.", forbiddenSchema),
404: jsonResponse("The marketplace could not be found.", notFoundSchema),
},
}),
async (c: OrgContext) => {
try {
const params = validParam<any>(c)
return c.json({ ok: true, item: await createResourceAccessGrant({ context: actorContext(c), resourceId: params.marketplaceId, resourceKind: "marketplace", value: validJson<any>(c) }) }, 201)
} catch (error) {
return routeErrorResponse(c, error)
}
})
withPluginArchOrgContext(app, "delete", pluginArchRoutePaths.marketplaceAccessGrant,
paramValidator(marketplaceAccessGrantParamsSchema),
describeRoute({
tags: ["Marketplaces"],
summary: "Revoke marketplace access",
description: "Soft-revokes one marketplace access grant.",
responses: {
204: emptyResponse("Marketplace access revoked successfully."),
400: jsonResponse("The marketplace access path parameters were invalid.", invalidRequestSchema),
401: jsonResponse("The caller must be signed in to manage marketplace access.", unauthorizedSchema),
403: jsonResponse("The caller lacks permission to manage marketplace access.", forbiddenSchema),
404: jsonResponse("The access grant could not be found.", notFoundSchema),
},
}),
async (c: OrgContext) => {
try {
const params = validParam<any>(c)
await deleteResourceAccessGrant({ context: actorContext(c), grantId: params.grantId, resourceId: params.marketplaceId, resourceKind: "marketplace" })
return c.body(null, 204)
} catch (error) {
return routeErrorResponse(c, error)
}
})
withPluginArchOrgContext(app, "get", pluginArchRoutePaths.connectorAccounts,
paramValidator(connectorAccountParamsSchema.pick({ orgId: true })),
queryValidator(connectorAccountListQuerySchema),

View File

@@ -11,6 +11,7 @@ import {
connectorSyncStatusValues,
connectorTargetKindValues,
connectorTypeValues,
marketplaceStatusValues,
membershipSourceValues,
pluginStatusValues,
} from "@openwork-ee/den-db/schema"
@@ -33,6 +34,9 @@ export const configObjectAccessGrantIdSchema = denTypeIdSchema("configObjectAcce
export const pluginIdSchema = denTypeIdSchema("plugin")
export const pluginConfigObjectIdSchema = denTypeIdSchema("pluginConfigObject")
export const pluginAccessGrantIdSchema = denTypeIdSchema("pluginAccessGrant")
export const marketplaceIdSchema = denTypeIdSchema("marketplace")
export const marketplacePluginIdSchema = denTypeIdSchema("marketplacePlugin")
export const marketplaceAccessGrantIdSchema = denTypeIdSchema("marketplaceAccessGrant")
export const connectorAccountIdSchema = denTypeIdSchema("connectorAccount")
export const connectorInstanceIdSchema = denTypeIdSchema("connectorInstance")
export const connectorInstanceAccessGrantIdSchema = denTypeIdSchema("connectorInstanceAccessGrant")
@@ -49,6 +53,7 @@ export const configObjectSourceModeSchema = z.enum(configObjectSourceModeValues)
export const configObjectCreatedViaSchema = z.enum(configObjectCreatedViaValues)
export const configObjectStatusSchema = z.enum(configObjectStatusValues)
export const pluginStatusSchema = z.enum(pluginStatusValues)
export const marketplaceStatusSchema = z.enum(marketplaceStatusValues)
export const membershipSourceSchema = z.enum(membershipSourceValues)
export const accessRoleSchema = z.enum(accessRoleValues)
export const connectorTypeSchema = z.enum(connectorTypeValues)
@@ -84,6 +89,11 @@ export const pluginListQuerySchema = pluginArchPaginationQuerySchema.extend({
q: z.string().trim().min(1).max(255).optional(),
})
export const marketplaceListQuerySchema = pluginArchPaginationQuerySchema.extend({
status: marketplaceStatusSchema.optional(),
q: z.string().trim().min(1).max(255).optional(),
})
export const connectorAccountListQuerySchema = pluginArchPaginationQuerySchema.extend({
connectorType: connectorTypeSchema.optional(),
status: connectorAccountStatusSchema.optional(),
@@ -128,6 +138,9 @@ export const configObjectAccessGrantParamsSchema = configObjectParamsSchema.exte
export const pluginParamsSchema = orgIdParamSchema.extend(idParamSchema("pluginId", "plugin").shape)
export const pluginConfigObjectParamsSchema = pluginParamsSchema.extend(idParamSchema("configObjectId", "configObject").shape)
export const pluginAccessGrantParamsSchema = pluginParamsSchema.extend(idParamSchema("grantId", "pluginAccessGrant").shape)
export const marketplaceParamsSchema = orgIdParamSchema.extend(idParamSchema("marketplaceId", "marketplace").shape)
export const marketplacePluginParamsSchema = marketplaceParamsSchema.extend(idParamSchema("pluginId", "plugin").shape)
export const marketplaceAccessGrantParamsSchema = marketplaceParamsSchema.extend(idParamSchema("grantId", "marketplaceAccessGrant").shape)
export const connectorAccountParamsSchema = orgIdParamSchema.extend(idParamSchema("connectorAccountId", "connectorAccount").shape)
export const connectorInstanceParamsSchema = orgIdParamSchema.extend(idParamSchema("connectorInstanceId", "connectorInstance").shape)
export const connectorInstanceAccessGrantParamsSchema = connectorInstanceParamsSchema.extend(idParamSchema("grantId", "connectorInstanceAccessGrant").shape)
@@ -204,11 +217,34 @@ export const pluginUpdateSchema = z.object({
}
})
export const marketplaceCreateSchema = z.object({
name: z.string().trim().min(1).max(255),
description: nullableStringSchema.optional(),
})
export const marketplaceUpdateSchema = z.object({
name: z.string().trim().min(1).max(255).optional(),
description: nullableStringSchema.optional(),
}).superRefine((value, ctx) => {
if (value.name === undefined && value.description === undefined) {
ctx.addIssue({
code: z.ZodIssueCode.custom,
message: "Provide at least one field to update.",
path: ["name"],
})
}
})
export const pluginMembershipWriteSchema = z.object({
configObjectId: configObjectIdSchema,
membershipSource: membershipSourceSchema.optional(),
})
export const marketplacePluginWriteSchema = z.object({
pluginId: pluginIdSchema,
membershipSource: membershipSourceSchema.optional(),
})
export const connectorAccountCreateSchema = z.object({
connectorType: connectorTypeSchema,
remoteId: z.string().trim().min(1).max(255),
@@ -324,7 +360,7 @@ export const githubValidateTargetSchema = z.object({
})
export const accessGrantSchema = z.object({
id: z.union([configObjectAccessGrantIdSchema, pluginAccessGrantIdSchema, connectorInstanceAccessGrantIdSchema]),
id: z.union([configObjectAccessGrantIdSchema, pluginAccessGrantIdSchema, marketplaceAccessGrantIdSchema, connectorInstanceAccessGrantIdSchema]),
orgMembershipId: memberIdSchema.nullable(),
teamId: teamIdSchema.nullable(),
orgWide: z.boolean(),
@@ -393,6 +429,30 @@ export const pluginSchema = z.object({
memberCount: z.number().int().nonnegative().optional(),
}).meta({ ref: "PluginArchPlugin" })
export const marketplacePluginSchema = z.object({
id: marketplacePluginIdSchema,
marketplaceId: marketplaceIdSchema,
pluginId: pluginIdSchema,
membershipSource: membershipSourceSchema,
createdByOrgMembershipId: memberIdSchema.nullable(),
createdAt: z.string().datetime({ offset: true }),
removedAt: nullableTimestampSchema,
plugin: pluginSchema.optional(),
}).meta({ ref: "PluginArchMarketplacePluginMembership" })
export const marketplaceSchema = z.object({
id: marketplaceIdSchema,
organizationId: denTypeIdSchema("organization"),
name: z.string().trim().min(1).max(255),
description: nullableStringSchema,
status: marketplaceStatusSchema,
createdByOrgMembershipId: memberIdSchema,
createdAt: z.string().datetime({ offset: true }),
updatedAt: z.string().datetime({ offset: true }),
deletedAt: nullableTimestampSchema,
pluginCount: z.number().int().nonnegative().optional(),
}).meta({ ref: "PluginArchMarketplace" })
export const connectorAccountSchema = z.object({
id: connectorAccountIdSchema,
organizationId: denTypeIdSchema("organization"),
@@ -609,6 +669,11 @@ export const pluginMutationResponseSchema = pluginArchMutationResponseSchema("Pl
export const pluginMembershipListResponseSchema = pluginArchListResponseSchema("PluginArchPluginMembershipListResponse", pluginMembershipSchema)
export const pluginMembershipDetailResponseSchema = pluginArchDetailResponseSchema("PluginArchPluginMembershipDetailResponse", pluginMembershipSchema)
export const pluginMembershipMutationResponseSchema = pluginArchMutationResponseSchema("PluginArchPluginMembershipMutationResponse", pluginMembershipSchema)
export const marketplaceListResponseSchema = pluginArchListResponseSchema("PluginArchMarketplaceListResponse", marketplaceSchema)
export const marketplaceDetailResponseSchema = pluginArchDetailResponseSchema("PluginArchMarketplaceDetailResponse", marketplaceSchema)
export const marketplaceMutationResponseSchema = pluginArchMutationResponseSchema("PluginArchMarketplaceMutationResponse", marketplaceSchema)
export const marketplacePluginListResponseSchema = pluginArchListResponseSchema("PluginArchMarketplacePluginListResponse", marketplacePluginSchema)
export const marketplacePluginMutationResponseSchema = pluginArchMutationResponseSchema("PluginArchMarketplacePluginMutationResponse", marketplacePluginSchema)
export const accessGrantListResponseSchema = pluginArchListResponseSchema("PluginArchAccessGrantListResponse", accessGrantSchema)
export const accessGrantMutationResponseSchema = pluginArchMutationResponseSchema("PluginArchAccessGrantMutationResponse", accessGrantSchema)
export const connectorAccountListResponseSchema = pluginArchListResponseSchema("PluginArchConnectorAccountListResponse", connectorAccountSchema)

View File

@@ -11,6 +11,9 @@ import {
ConnectorSourceTombstoneTable,
ConnectorSyncEventTable,
ConnectorTargetTable,
MarketplaceAccessGrantTable,
MarketplacePluginTable,
MarketplaceTable,
PluginAccessGrantTable,
PluginConfigObjectTable,
PluginTable,
@@ -25,17 +28,23 @@ type MemberId = PluginArchActorContext["organizationContext"]["currentMember"]["
type TeamId = PluginArchActorContext["memberTeams"][number]["id"]
type ConfigObjectRow = typeof ConfigObjectTable.$inferSelect
type ConfigObjectVersionRow = typeof ConfigObjectVersionTable.$inferSelect
type MarketplaceRow = typeof MarketplaceTable.$inferSelect
type MarketplaceMembershipRow = typeof MarketplacePluginTable.$inferSelect
type PluginRow = typeof PluginTable.$inferSelect
type PluginMembershipRow = typeof PluginConfigObjectTable.$inferSelect
type ConfigObjectId = ConfigObjectRow["id"]
type ConfigObjectVersionId = ConfigObjectVersionRow["id"]
type MarketplaceId = MarketplaceRow["id"]
type MarketplaceMembershipId = MarketplaceMembershipRow["id"]
type PluginId = PluginRow["id"]
type PluginMembershipId = PluginMembershipRow["id"]
type AccessGrantRow =
| typeof ConfigObjectAccessGrantTable.$inferSelect
| typeof MarketplaceAccessGrantTable.$inferSelect
| typeof PluginAccessGrantTable.$inferSelect
| typeof ConnectorInstanceAccessGrantTable.$inferSelect
type ConfigObjectAccessGrantId = typeof ConfigObjectAccessGrantTable.$inferSelect.id
type MarketplaceAccessGrantId = typeof MarketplaceAccessGrantTable.$inferSelect.id
type PluginAccessGrantId = typeof PluginAccessGrantTable.$inferSelect.id
type ConnectorInstanceAccessGrantId = typeof ConnectorInstanceAccessGrantTable.$inferSelect.id
type ConnectorAccountRow = typeof ConnectorAccountTable.$inferSelect
@@ -86,6 +95,11 @@ type PluginResourceTarget = {
resourceKind: "plugin"
}
type MarketplaceResourceTarget = {
resourceId: MarketplaceId
resourceKind: "marketplace"
}
type ConnectorInstanceResourceTarget = {
resourceId: ConnectorInstanceId
resourceKind: "connector_instance"
@@ -93,13 +107,15 @@ type ConnectorInstanceResourceTarget = {
type ResourceTarget =
| ConfigObjectResourceTarget
| MarketplaceResourceTarget
| PluginResourceTarget
| ConnectorInstanceResourceTarget
type ConfigObjectGrantTarget = ConfigObjectResourceTarget & { grantId: ConfigObjectAccessGrantId }
type MarketplaceGrantTarget = MarketplaceResourceTarget & { grantId: MarketplaceAccessGrantId }
type PluginGrantTarget = PluginResourceTarget & { grantId: PluginAccessGrantId }
type ConnectorInstanceGrantTarget = ConnectorInstanceResourceTarget & { grantId: ConnectorInstanceAccessGrantId }
type GrantTarget = ConfigObjectGrantTarget | PluginGrantTarget | ConnectorInstanceGrantTarget
type GrantTarget = ConfigObjectGrantTarget | MarketplaceGrantTarget | PluginGrantTarget | ConnectorInstanceGrantTarget
export class PluginArchRouteFailure extends Error {
constructor(
@@ -254,6 +270,21 @@ function serializePlugin(row: PluginRow, memberCount?: number) {
}
}
function serializeMarketplace(row: MarketplaceRow, pluginCount?: number) {
return {
createdAt: row.createdAt.toISOString(),
createdByOrgMembershipId: row.createdByOrgMembershipId,
deletedAt: row.deletedAt ? row.deletedAt.toISOString() : null,
description: row.description,
id: row.id,
name: row.name,
organizationId: row.organizationId,
pluginCount,
status: row.status,
updatedAt: row.updatedAt.toISOString(),
}
}
function serializeMembership(row: PluginMembershipRow, configObject?: ReturnType<typeof serializeConfigObject>) {
return {
configObject,
@@ -268,6 +299,19 @@ function serializeMembership(row: PluginMembershipRow, configObject?: ReturnType
}
}
function serializeMarketplaceMembership(row: MarketplaceMembershipRow, plugin?: ReturnType<typeof serializePlugin>) {
return {
createdAt: row.createdAt.toISOString(),
createdByOrgMembershipId: row.createdByOrgMembershipId,
id: row.id,
marketplaceId: row.marketplaceId,
membershipSource: row.membershipSource,
plugin,
pluginId: row.pluginId,
removedAt: row.removedAt ? row.removedAt.toISOString() : null,
}
}
function serializeAccessGrant(row: AccessGrantRow) {
return {
createdAt: row.createdAt.toISOString(),
@@ -385,6 +429,16 @@ async function getPluginRow(organizationId: OrganizationId, pluginId: PluginId)
return rows[0] ?? null
}
async function getMarketplaceRow(organizationId: OrganizationId, marketplaceId: MarketplaceId) {
const rows = await db
.select()
.from(MarketplaceTable)
.where(and(eq(MarketplaceTable.organizationId, organizationId), eq(MarketplaceTable.id, marketplaceId)))
.limit(1)
return rows[0] ?? null
}
async function getConnectorAccountRow(organizationId: OrganizationId, connectorAccountId: ConnectorAccountId) {
const rows = await db
.select()
@@ -456,6 +510,24 @@ async function ensureEditablePlugin(context: PluginArchActorContext, pluginId: P
return row
}
async function ensureEditableMarketplace(context: PluginArchActorContext, marketplaceId: MarketplaceId) {
const row = await getMarketplaceRow(context.organizationContext.organization.id, marketplaceId)
if (!row) {
throw new PluginArchRouteFailure(404, "marketplace_not_found", "Marketplace not found.")
}
await requirePluginArchResourceRole({ context, resourceId: row.id, resourceKind: "marketplace", role: "editor" })
return row
}
async function ensureVisibleMarketplace(context: PluginArchActorContext, marketplaceId: MarketplaceId) {
const row = await getMarketplaceRow(context.organizationContext.organization.id, marketplaceId)
if (!row) {
throw new PluginArchRouteFailure(404, "marketplace_not_found", "Marketplace not found.")
}
await requirePluginArchResourceRole({ context, resourceId: row.id, resourceKind: "marketplace", role: "viewer" })
return row
}
async function ensureVisiblePlugin(context: PluginArchActorContext, pluginId: PluginId) {
const row = await getPluginRow(context.organizationContext.organization.id, pluginId)
if (!row) {
@@ -535,6 +607,50 @@ async function upsertGrant(input: ResourceTarget & {
return serializeAccessGrant({ ...row, removedAt: null })
}
if (input.resourceKind === "marketplace") {
const existing = await db
.select()
.from(MarketplaceAccessGrantTable)
.where(and(
eq(MarketplaceAccessGrantTable.marketplaceId, input.resourceId),
input.value.orgMembershipId
? eq(MarketplaceAccessGrantTable.orgMembershipId, input.value.orgMembershipId)
: input.value.teamId
? eq(MarketplaceAccessGrantTable.teamId, input.value.teamId)
: eq(MarketplaceAccessGrantTable.orgWide, true),
))
.limit(1)
if (existing[0]) {
await db
.update(MarketplaceAccessGrantTable)
.set({
createdByOrgMembershipId,
orgMembershipId: input.value.orgMembershipId ?? null,
orgWide: input.value.orgWide ?? false,
removedAt: null,
role: input.value.role,
teamId: input.value.teamId ?? null,
})
.where(eq(MarketplaceAccessGrantTable.id, existing[0].id))
return serializeAccessGrant({ ...existing[0], createdByOrgMembershipId, orgMembershipId: input.value.orgMembershipId ?? null, orgWide: input.value.orgWide ?? false, removedAt: null, role: input.value.role, teamId: input.value.teamId ?? null })
}
const row = {
createdAt,
createdByOrgMembershipId,
id: createDenTypeId("marketplaceAccessGrant"),
marketplaceId: input.resourceId,
organizationId,
orgMembershipId: input.value.orgMembershipId ?? null,
orgWide: input.value.orgWide ?? false,
role: input.value.role,
teamId: input.value.teamId ?? null,
}
await db.insert(MarketplaceAccessGrantTable).values(row)
return serializeAccessGrant({ ...row, removedAt: null })
}
if (input.resourceKind === "plugin") {
const existing = await db
.select()
@@ -634,6 +750,16 @@ async function removeGrant(input: GrantTarget & { context: PluginArchActorContex
await db.update(ConfigObjectAccessGrantTable).set({ removedAt }).where(eq(ConfigObjectAccessGrantTable.id, input.grantId))
return
}
if (input.resourceKind === "marketplace") {
const rows = await db
.select()
.from(MarketplaceAccessGrantTable)
.where(and(eq(MarketplaceAccessGrantTable.id, input.grantId), eq(MarketplaceAccessGrantTable.marketplaceId, input.resourceId)))
.limit(1)
if (!rows[0]) throw new PluginArchRouteFailure(404, "access_grant_not_found", "Access grant not found.")
await db.update(MarketplaceAccessGrantTable).set({ removedAt }).where(eq(MarketplaceAccessGrantTable.id, input.grantId))
return
}
if (input.resourceKind === "plugin") {
const rows = await db
.select()
@@ -970,6 +1096,10 @@ export async function listResourceAccess(input: { context: PluginArchActorContex
const rows = await db.select().from(ConfigObjectAccessGrantTable).where(eq(ConfigObjectAccessGrantTable.configObjectId, input.resourceId)).orderBy(desc(ConfigObjectAccessGrantTable.createdAt))
return { items: rows.map((row) => serializeAccessGrant(row)), nextCursor: null }
}
if (input.resourceKind === "marketplace") {
const rows = await db.select().from(MarketplaceAccessGrantTable).where(eq(MarketplaceAccessGrantTable.marketplaceId, input.resourceId)).orderBy(desc(MarketplaceAccessGrantTable.createdAt))
return { items: rows.map((row) => serializeAccessGrant(row)), nextCursor: null }
}
if (input.resourceKind === "plugin") {
const rows = await db.select().from(PluginAccessGrantTable).where(eq(PluginAccessGrantTable.pluginId, input.resourceId)).orderBy(desc(PluginAccessGrantTable.createdAt))
return { items: rows.map((row) => serializeAccessGrant(row)), nextCursor: null }
@@ -1107,6 +1237,166 @@ export async function removePluginMembership(input: { configObjectId: ConfigObje
return removeConfigObjectFromPlugin(input)
}
export async function listMarketplaces(input: { context: PluginArchActorContext; cursor?: string; limit?: number; q?: string; status?: MarketplaceRow["status"] }) {
const rows = await db
.select()
.from(MarketplaceTable)
.where(eq(MarketplaceTable.organizationId, input.context.organizationContext.organization.id))
.orderBy(desc(MarketplaceTable.updatedAt), desc(MarketplaceTable.id))
const memberships = await db
.select({ marketplaceId: MarketplacePluginTable.marketplaceId, count: MarketplacePluginTable.id })
.from(MarketplacePluginTable)
.where(isNull(MarketplacePluginTable.removedAt))
const counts = memberships.reduce((accumulator, row) => {
accumulator.set(row.marketplaceId, (accumulator.get(row.marketplaceId) ?? 0) + 1)
return accumulator
}, new Map<string, number>())
const visible: ReturnType<typeof serializeMarketplace>[] = []
for (const row of rows) {
const role = await resolvePluginArchResourceRole({ context: input.context, resourceId: row.id, resourceKind: "marketplace" })
if (!role) continue
if (input.status && row.status !== input.status) continue
if (input.q) {
const haystack = `${row.name}\n${row.description ?? ""}`.toLowerCase()
if (!haystack.includes(input.q.toLowerCase())) continue
}
visible.push(serializeMarketplace(row, counts.get(row.id) ?? 0))
}
return pageItems(visible, input.cursor, input.limit)
}
export async function getMarketplaceDetail(context: PluginArchActorContext, marketplaceId: MarketplaceId) {
const row = await ensureVisibleMarketplace(context, marketplaceId)
const memberships = await db
.select({ id: MarketplacePluginTable.id })
.from(MarketplacePluginTable)
.where(and(eq(MarketplacePluginTable.marketplaceId, row.id), isNull(MarketplacePluginTable.removedAt)))
return serializeMarketplace(row, memberships.length)
}
export async function createMarketplace(input: { context: PluginArchActorContext; description?: string | null; name: string }) {
const now = new Date()
const row = {
createdAt: now,
createdByOrgMembershipId: input.context.organizationContext.currentMember.id,
deletedAt: null,
description: normalizeOptionalString(input.description ?? undefined),
id: createDenTypeId("marketplace"),
name: input.name.trim(),
organizationId: input.context.organizationContext.organization.id,
status: "active" as const,
updatedAt: now,
}
await db.transaction(async (tx) => {
await tx.insert(MarketplaceTable).values(row)
await tx.insert(MarketplaceAccessGrantTable).values({
createdAt: now,
createdByOrgMembershipId: input.context.organizationContext.currentMember.id,
id: createDenTypeId("marketplaceAccessGrant"),
marketplaceId: row.id,
organizationId: input.context.organizationContext.organization.id,
orgMembershipId: input.context.organizationContext.currentMember.id,
orgWide: false,
role: "manager",
teamId: null,
})
})
return serializeMarketplace(row, 0)
}
export async function updateMarketplace(input: { context: PluginArchActorContext; description?: string | null; marketplaceId: MarketplaceId; name?: string }) {
const row = await ensureEditableMarketplace(input.context, input.marketplaceId)
const updatedAt = new Date()
await db.update(MarketplaceTable).set({
description: input.description === undefined ? row.description : normalizeOptionalString(input.description ?? undefined),
name: input.name?.trim() || row.name,
updatedAt,
}).where(eq(MarketplaceTable.id, row.id))
return getMarketplaceDetail(input.context, row.id)
}
export async function setMarketplaceLifecycle(input: { action: "archive" | "restore"; context: PluginArchActorContext; marketplaceId: MarketplaceId }) {
const row = await ensureVisibleMarketplace(input.context, input.marketplaceId)
await requirePluginArchResourceRole({ context: input.context, resourceId: row.id, resourceKind: "marketplace", role: "manager" })
const updatedAt = new Date()
await db.update(MarketplaceTable).set({
deletedAt: input.action === "archive" ? row.deletedAt : null,
status: input.action === "archive" ? "archived" : "active",
updatedAt,
}).where(eq(MarketplaceTable.id, row.id))
return getMarketplaceDetail(input.context, row.id)
}
export async function listMarketplaceMemberships(input: { context: PluginArchActorContext; includePlugins?: boolean; marketplaceId: MarketplaceId; onlyActive?: boolean }) {
await ensureVisibleMarketplace(input.context, input.marketplaceId)
const memberships = await db
.select()
.from(MarketplacePluginTable)
.where(input.onlyActive ? and(eq(MarketplacePluginTable.marketplaceId, input.marketplaceId), isNull(MarketplacePluginTable.removedAt)) : eq(MarketplacePluginTable.marketplaceId, input.marketplaceId))
.orderBy(desc(MarketplacePluginTable.createdAt))
if (!input.includePlugins) {
return { items: memberships.map((membership) => serializeMarketplaceMembership(membership)), nextCursor: null }
}
const plugins = memberships.length === 0
? []
: await db.select().from(PluginTable).where(inArray(PluginTable.id, memberships.map((membership) => membership.pluginId)))
const byId = new Map<string, ReturnType<typeof serializePlugin>>(plugins.map((row) => [row.id, serializePlugin(row)]))
return { items: memberships.map((membership) => serializeMarketplaceMembership(membership, byId.get(membership.pluginId))), nextCursor: null }
}
export async function attachPluginToMarketplace(input: { context: PluginArchActorContext; marketplaceId: MarketplaceId; membershipSource?: MarketplaceMembershipRow["membershipSource"]; pluginId: PluginId }) {
await ensureVisiblePlugin(input.context, input.pluginId)
await ensureEditableMarketplace(input.context, input.marketplaceId)
const existing = await db
.select()
.from(MarketplacePluginTable)
.where(and(eq(MarketplacePluginTable.marketplaceId, input.marketplaceId), eq(MarketplacePluginTable.pluginId, input.pluginId)))
.limit(1)
const now = new Date()
let membershipId: MarketplaceMembershipId | null = existing[0]?.id ?? null
if (existing[0]) {
await db.update(MarketplacePluginTable).set({ membershipSource: input.membershipSource ?? existing[0].membershipSource, removedAt: null }).where(eq(MarketplacePluginTable.id, existing[0].id))
} else {
membershipId = createDenTypeId("marketplacePlugin")
await db.insert(MarketplacePluginTable).values({
createdAt: now,
createdByOrgMembershipId: input.context.organizationContext.currentMember.id,
id: membershipId,
marketplaceId: input.marketplaceId,
membershipSource: input.membershipSource ?? "manual",
organizationId: input.context.organizationContext.organization.id,
pluginId: input.pluginId,
})
}
const rows = await db.select().from(MarketplacePluginTable).where(eq(MarketplacePluginTable.id, membershipId!)).limit(1)
return serializeMarketplaceMembership(rows[0])
}
export async function removePluginFromMarketplace(input: { context: PluginArchActorContext; marketplaceId: MarketplaceId; pluginId: PluginId }) {
await ensureVisiblePlugin(input.context, input.pluginId)
await ensureEditableMarketplace(input.context, input.marketplaceId)
const rows = await db
.select()
.from(MarketplacePluginTable)
.where(and(eq(MarketplacePluginTable.marketplaceId, input.marketplaceId), eq(MarketplacePluginTable.pluginId, input.pluginId), isNull(MarketplacePluginTable.removedAt)))
.limit(1)
if (!rows[0]) {
throw new PluginArchRouteFailure(404, "marketplace_membership_not_found", "Marketplace membership not found.")
}
await db.update(MarketplacePluginTable).set({ removedAt: new Date() }).where(eq(MarketplacePluginTable.id, rows[0].id))
}
export async function listConnectorAccounts(input: { context: PluginArchActorContext; connectorType?: ConnectorAccountRow["connectorType"]; cursor?: string; limit?: number; q?: string; status?: ConnectorAccountRow["status"] }) {
const rows = await db
.select()

View File

@@ -41,6 +41,7 @@ test("org owners and admins get plugin-system capability access", () => {
expect(accessModule.isPluginArchOrgAdmin(createActorContext({ role: "member" }))).toBe(false)
expect(accessModule.hasPluginArchCapability(createActorContext({ isOwner: true }), "plugin.create")).toBe(true)
expect(accessModule.hasPluginArchCapability(createActorContext({ role: "admin" }), "marketplace.create")).toBe(true)
expect(accessModule.hasPluginArchCapability(createActorContext({ role: "admin" }), "connector_instance.create")).toBe(true)
expect(accessModule.hasPluginArchCapability(createActorContext({ role: "member" }), "config_object.create")).toBe(false)
})

View File

@@ -0,0 +1,53 @@
CREATE TABLE IF NOT EXISTS `marketplace` (
`id` varchar(64) NOT NULL,
`organization_id` varchar(64) NOT NULL,
`name` varchar(255) NOT NULL,
`description` text,
`status` enum('active','inactive','deleted','archived') NOT NULL DEFAULT 'active',
`created_by_org_membership_id` varchar(64) NOT NULL,
`created_at` timestamp(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3),
`updated_at` timestamp(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3),
`deleted_at` timestamp(3) NULL,
CONSTRAINT `marketplace_id` PRIMARY KEY(`id`),
KEY `marketplace_organization_id` (`organization_id`),
KEY `marketplace_created_by_org_membership_id` (`created_by_org_membership_id`),
KEY `marketplace_status` (`status`),
KEY `marketplace_name` (`name`)
);
CREATE TABLE IF NOT EXISTS `marketplace_plugin` (
`id` varchar(64) NOT NULL,
`organization_id` varchar(64) NOT NULL,
`marketplace_id` varchar(64) NOT NULL,
`plugin_id` varchar(64) NOT NULL,
`membership_source` enum('manual','connector','api','system') NOT NULL DEFAULT 'manual',
`created_by_org_membership_id` varchar(64),
`created_at` timestamp(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3),
`removed_at` timestamp(3) NULL,
CONSTRAINT `marketplace_plugin_id` PRIMARY KEY(`id`),
CONSTRAINT `marketplace_plugin_marketplace_plugin` UNIQUE(`marketplace_id`, `plugin_id`),
KEY `marketplace_plugin_organization_id` (`organization_id`),
KEY `marketplace_plugin_marketplace_id` (`marketplace_id`),
KEY `marketplace_plugin_plugin_id` (`plugin_id`)
);
CREATE TABLE IF NOT EXISTS `marketplace_access_grant` (
`id` varchar(64) NOT NULL,
`organization_id` varchar(64) NOT NULL,
`marketplace_id` varchar(64) NOT NULL,
`org_membership_id` varchar(64),
`team_id` varchar(64),
`org_wide` boolean NOT NULL DEFAULT false,
`role` enum('viewer','editor','manager') NOT NULL,
`created_by_org_membership_id` varchar(64) NOT NULL,
`created_at` timestamp(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3),
`removed_at` timestamp(3) NULL,
CONSTRAINT `marketplace_access_grant_id` PRIMARY KEY(`id`),
CONSTRAINT `marketplace_access_grant_marketplace_org_membership` UNIQUE(`marketplace_id`, `org_membership_id`),
CONSTRAINT `marketplace_access_grant_marketplace_team` UNIQUE(`marketplace_id`, `team_id`),
KEY `marketplace_access_grant_organization_id` (`organization_id`),
KEY `marketplace_access_grant_marketplace_id` (`marketplace_id`),
KEY `marketplace_access_grant_org_membership_id` (`org_membership_id`),
KEY `marketplace_access_grant_team_id` (`team_id`),
KEY `marketplace_access_grant_org_wide` (`org_wide`)
);

View File

@@ -71,6 +71,13 @@
"when": 1776427000000,
"tag": "0010_plugin_arch",
"breakpoints": true
},
{
"idx": 11,
"version": "5",
"when": 1776440000000,
"tag": "0011_marketplaces",
"breakpoints": true
}
]
}

View File

@@ -19,6 +19,7 @@ export const configObjectSourceModeValues = ["cloud", "import", "connector"] as
export const configObjectStatusValues = ["active", "inactive", "deleted", "archived", "ingestion_error"] as const
export const configObjectCreatedViaValues = ["cloud", "import", "connector", "system"] as const
export const pluginStatusValues = ["active", "inactive", "deleted", "archived"] as const
export const marketplaceStatusValues = ["active", "inactive", "deleted", "archived"] as const
export const membershipSourceValues = ["manual", "connector", "api", "system"] as const
export const accessRoleValues = ["viewer", "editor", "manager"] as const
export const connectorTypeValues = ["github"] as const
@@ -114,6 +115,72 @@ export const PluginTable = mysqlTable(
],
)
export const MarketplaceTable = mysqlTable(
"marketplace",
{
id: denTypeIdColumn("marketplace", "id").notNull().primaryKey(),
organizationId: denTypeIdColumn("organization", "organization_id").notNull(),
name: varchar("name", { length: 255 }).notNull(),
description: text("description"),
status: mysqlEnum("status", marketplaceStatusValues).notNull().default("active"),
createdByOrgMembershipId: denTypeIdColumn("member", "created_by_org_membership_id").notNull(),
createdAt: timestamp("created_at", { fsp: 3 }).notNull().defaultNow(),
updatedAt: timestamp("updated_at", { fsp: 3 }).notNull().default(sql`CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3)`),
deletedAt: timestamp("deleted_at", { fsp: 3 }),
},
(table) => [
index("marketplace_organization_id").on(table.organizationId),
index("marketplace_created_by_org_membership_id").on(table.createdByOrgMembershipId),
index("marketplace_status").on(table.status),
index("marketplace_name").on(table.name),
],
)
export const MarketplacePluginTable = mysqlTable(
"marketplace_plugin",
{
id: denTypeIdColumn("marketplacePlugin", "id").notNull().primaryKey(),
organizationId: denTypeIdColumn("organization", "organization_id").notNull(),
marketplaceId: denTypeIdColumn("marketplace", "marketplace_id").notNull(),
pluginId: denTypeIdColumn("plugin", "plugin_id").notNull(),
membershipSource: mysqlEnum("membership_source", membershipSourceValues).notNull().default("manual"),
createdByOrgMembershipId: denTypeIdColumn("member", "created_by_org_membership_id"),
createdAt: timestamp("created_at", { fsp: 3 }).notNull().defaultNow(),
removedAt: timestamp("removed_at", { fsp: 3 }),
},
(table) => [
index("marketplace_plugin_organization_id").on(table.organizationId),
index("marketplace_plugin_marketplace_id").on(table.marketplaceId),
index("marketplace_plugin_plugin_id").on(table.pluginId),
uniqueIndex("marketplace_plugin_marketplace_plugin").on(table.marketplaceId, table.pluginId),
],
)
export const MarketplaceAccessGrantTable = mysqlTable(
"marketplace_access_grant",
{
id: denTypeIdColumn("marketplaceAccessGrant", "id").notNull().primaryKey(),
organizationId: denTypeIdColumn("organization", "organization_id").notNull(),
marketplaceId: denTypeIdColumn("marketplace", "marketplace_id").notNull(),
orgMembershipId: denTypeIdColumn("member", "org_membership_id"),
teamId: denTypeIdColumn("team", "team_id"),
orgWide: boolean("org_wide").notNull().default(false),
role: mysqlEnum("role", accessRoleValues).notNull(),
createdByOrgMembershipId: denTypeIdColumn("member", "created_by_org_membership_id").notNull(),
createdAt: timestamp("created_at", { fsp: 3 }).notNull().defaultNow(),
removedAt: timestamp("removed_at", { fsp: 3 }),
},
(table) => [
index("marketplace_access_grant_organization_id").on(table.organizationId),
index("marketplace_access_grant_marketplace_id").on(table.marketplaceId),
index("marketplace_access_grant_org_membership_id").on(table.orgMembershipId),
index("marketplace_access_grant_team_id").on(table.teamId),
index("marketplace_access_grant_org_wide").on(table.orgWide),
uniqueIndex("marketplace_access_grant_marketplace_org_membership").on(table.marketplaceId, table.orgMembershipId),
uniqueIndex("marketplace_access_grant_marketplace_team").on(table.marketplaceId, table.teamId),
],
)
export const PluginConfigObjectTable = mysqlTable(
"plugin_config_object",
{
@@ -438,6 +505,7 @@ export const pluginRelations = relations(PluginTable, ({ many, one }) => ({
fields: [PluginTable.createdByOrgMembershipId],
references: [MemberTable.id],
}),
marketplaces: many(MarketplacePluginTable),
memberships: many(PluginConfigObjectTable),
organization: one(OrganizationTable, {
fields: [PluginTable.organizationId],
@@ -446,6 +514,53 @@ export const pluginRelations = relations(PluginTable, ({ many, one }) => ({
mappings: many(ConnectorMappingTable),
}))
export const marketplaceRelations = relations(MarketplaceTable, ({ many, one }) => ({
accessGrants: many(MarketplaceAccessGrantTable),
createdByOrgMembership: one(MemberTable, {
fields: [MarketplaceTable.createdByOrgMembershipId],
references: [MemberTable.id],
}),
memberships: many(MarketplacePluginTable),
organization: one(OrganizationTable, {
fields: [MarketplaceTable.organizationId],
references: [OrganizationTable.id],
}),
}))
export const marketplacePluginRelations = relations(MarketplacePluginTable, ({ one }) => ({
createdByOrgMembership: one(MemberTable, {
fields: [MarketplacePluginTable.createdByOrgMembershipId],
references: [MemberTable.id],
}),
marketplace: one(MarketplaceTable, {
fields: [MarketplacePluginTable.marketplaceId],
references: [MarketplaceTable.id],
}),
plugin: one(PluginTable, {
fields: [MarketplacePluginTable.pluginId],
references: [PluginTable.id],
}),
}))
export const marketplaceAccessGrantRelations = relations(MarketplaceAccessGrantTable, ({ one }) => ({
createdByOrgMembership: one(MemberTable, {
fields: [MarketplaceAccessGrantTable.createdByOrgMembershipId],
references: [MemberTable.id],
}),
marketplace: one(MarketplaceTable, {
fields: [MarketplaceAccessGrantTable.marketplaceId],
references: [MarketplaceTable.id],
}),
orgMembership: one(MemberTable, {
fields: [MarketplaceAccessGrantTable.orgMembershipId],
references: [MemberTable.id],
}),
team: one(TeamTable, {
fields: [MarketplaceAccessGrantTable.teamId],
references: [TeamTable.id],
}),
}))
export const pluginConfigObjectRelations = relations(PluginConfigObjectTable, ({ one }) => ({
configObject: one(ConfigObjectTable, {
fields: [PluginConfigObjectTable.configObjectId],
@@ -643,6 +758,9 @@ export const connectorSourceTombstoneRelations = relations(ConnectorSourceTombst
export const configObject = ConfigObjectTable
export const configObjectVersion = ConfigObjectVersionTable
export const plugin = PluginTable
export const marketplace = MarketplaceTable
export const marketplacePlugin = MarketplacePluginTable
export const marketplaceAccessGrant = MarketplaceAccessGrantTable
export const pluginConfigObject = PluginConfigObjectTable
export const configObjectAccessGrant = ConfigObjectAccessGrantTable
export const pluginAccessGrant = PluginAccessGrantTable

View File

@@ -32,6 +32,9 @@ export const idTypesMapNameToPrefix = {
plugin: "plg",
pluginConfigObject: "pco",
pluginAccessGrant: "pag",
marketplace: "mkt",
marketplacePlugin: "mkp",
marketplaceAccessGrant: "mag",
connectorAccount: "cac",
connectorInstance: "cin",
connectorInstanceAccessGrant: "cia",