diff --git a/README.md b/README.md index d3a78eb1..e2772bbc 100644 --- a/README.md +++ b/README.md @@ -25,7 +25,7 @@ mosoo provides a Cloudflare-native control plane to stream tool activity, inspect Run history, and keep Threads and files across executions. It is self-hostable in your own account. -Your application remains yours. Its backend owns product behavior and end-user access. mosoo focuses on Agent execution and lifecycle; App Deployment is a separate Alpha surface, not the core product contract. +Your application remains yours. Its backend owns product behavior and end-user access. mosoo focuses on Agent execution and lifecycle. ## How It Works diff --git a/apps/api/.dev.vars.example b/apps/api/.dev.vars.example index f8613877..95fa40bf 100644 --- a/apps/api/.dev.vars.example +++ b/apps/api/.dev.vars.example @@ -6,6 +6,4 @@ GOOGLE_OAUTH_CLIENT_SECRET= R2_ACCESS_KEY_ID= R2_SECRET_ACCESS_KEY= CLOUDFLARE_ACCOUNT_ID= -CLOUDFLARE_API_TOKEN= -CLOUDFLARE_ZONE_ID= SKILLS_SH_API_TOKEN= diff --git a/apps/api/bin/init-dev-vars.ts b/apps/api/bin/init-dev-vars.ts index dea09760..7d706838 100755 --- a/apps/api/bin/init-dev-vars.ts +++ b/apps/api/bin/init-dev-vars.ts @@ -28,8 +28,6 @@ const devVarSpecs: readonly DevVarSpec[] = [ { key: "R2_ACCESS_KEY_ID", required: false }, { key: "R2_SECRET_ACCESS_KEY", required: false }, { key: "CLOUDFLARE_ACCOUNT_ID", required: false }, - { key: "CLOUDFLARE_API_TOKEN", required: false }, - { key: "CLOUDFLARE_ZONE_ID", required: false }, { key: "SKILLS_SH_API_TOKEN", required: false }, ]; diff --git a/apps/api/package.json b/apps/api/package.json index 80fa6125..50e33696 100644 --- a/apps/api/package.json +++ b/apps/api/package.json @@ -36,8 +36,6 @@ "graphql": "^17.0.2", "graphql-yoga": "^5.21.3", "hono": "^4.12.32", - "jsonc-parser": "3.3.1", - "smol-toml": "1.8.0", "xstate": "^5.32.0" }, "devDependencies": { diff --git a/apps/api/src/adapters/graphql/graphql-module-specs.ts b/apps/api/src/adapters/graphql/graphql-module-specs.ts index 3cc794dc..1b8e84e3 100644 --- a/apps/api/src/adapters/graphql/graphql-module-specs.ts +++ b/apps/api/src/adapters/graphql/graphql-module-specs.ts @@ -97,13 +97,9 @@ export const onboardingGraphQLSpec = { export const appGraphQLSpec = { mutationFields: [ "createApp(input: CreateAppInput!): App!", - "deleteAppDeployment(input: DeleteAppDeploymentInput!): OperationResult!", - "deployApp(input: DeployAppInput!): AppDeploymentRun!", "renameApp(input: RenameAppInput!): App!", ], queryFields: [ - "appDeploymentRunList(appId: ULID!, limit: Int): [AppDeploymentRun!]!", - "appDeploymentStatus(appId: ULID!): AppDeploymentRun", "appList(organizationId: ULID!): [App!]!", "appOverview(appId: ULID!, agentLimit: Int, credentialLimit: Int): AppOverview!", "controlPlaneOverview(appLimit: Int, agentLimit: Int, credentialLimit: Int): ControlPlaneOverview!", @@ -127,7 +123,6 @@ export const sessionGraphQLSpec = { ], queryFields: [ "agentSessionDiagnostics(appId: ULID!, sessionId: ULID!): AgentSessionDiagnostics!", - "boundCapabilityRunProvenance(appId: ULID!, runId: ULID!): BoundCapabilityRunProvenance", "agentSessionRetrieve(appId: ULID!, sessionId: ULID!): AgentSessionRetrieve!", "session(appId: ULID!, sessionId: ULID!): Session!", "sessionMessages(appId: ULID!, sessionId: ULID!): [SessionMessage!]!", diff --git a/apps/api/src/adapters/graphql/schema.generated.graphql b/apps/api/src/adapters/graphql/schema.generated.graphql index bf4b9281..7a84ede9 100644 --- a/apps/api/src/adapters/graphql/schema.generated.graphql +++ b/apps/api/src/adapters/graphql/schema.generated.graphql @@ -454,52 +454,6 @@ type AppCostCard { totals: CostTotals! } -type AppDeployment { - appId: ULID! - createdAt: String! - defaultBranch: String! - id: ULID! - latestRun: AppDeploymentRun - liveUrl: String - plannedUrl: String! - repoName: String! - repoOwner: String! - repoUrl: String! - updatedAt: String! -} - -type AppDeploymentRun { - appId: ULID! - createdAt: String! - deploymentId: ULID! - errorCode: String - errorMessage: String - id: ULID! - liveUrl: String - plannedUrl: String! - sourceBranch: String! - sourceCommitSha: String! - status: AppDeploymentRunStatus! - targetKind: AppDeploymentTargetKind - updatedAt: String! -} - -enum AppDeploymentRunStatus { - activating - building - failed - preparing - queued - submitted - submitting - success -} - -enum AppDeploymentTargetKind { - cloudflare_pages - cloudflare_worker -} - type AppInfo { api: String! name: String! @@ -509,8 +463,6 @@ type AppInfo { type AppOverview { agents: AppOverviewAgentList! app: App! - boundAgents: [AppOverviewBoundAgent!]! - deployment: AppDeployment providerCredentials: AppOverviewProviderCredentialList! } @@ -533,17 +485,6 @@ type AppOverviewAgentList { limit: Int! } -type AppOverviewBoundAgent { - agentId: ULID! - envVar: String! - expose: AppOverviewBoundAgentExposure! - name: String! -} - -enum AppOverviewBoundAgentExposure { - public_thread -} - type AppOverviewProviderCredential { appId: ULID! hasCustomApiBase: Boolean! @@ -588,16 +529,6 @@ input BootstrapOnboardingInput { name: String } -type BoundCapabilityRunProvenance { - agentId: ULID! - appId: ULID! - bindingEnv: String! - bindingName: String! - deploymentId: ULID! - deploymentRunId: ULID! - runId: ULID! -} - input ConnectMcpBearerInput { appId: ULID! serverId: ULID! @@ -807,10 +738,6 @@ input DeleteAgentInput { appId: ULID! } -input DeleteAppDeploymentInput { - appId: ULID! -} - input DeleteEnvironmentInput { appId: ULID! environmentId: ULID! @@ -821,12 +748,6 @@ input DeleteVendorCredentialInput { id: ULID! } -input DeployAppInput { - appId: ULID! - configPath: String - repoUrl: String! -} - type EnvironmentDetail { allowMcpServers: Boolean! allowPackageManagers: Boolean! @@ -1157,12 +1078,10 @@ type Mutation { createVendorCredential(input: CreateVendorCredentialInput!): VendorCredential! deleteAgent(input: DeleteAgentInput!): OperationResult! deleteAgentSession(appId: ULID!, sessionId: ULID!): OperationResult! - deleteAppDeployment(input: DeleteAppDeploymentInput!): OperationResult! deleteEnvironment(input: DeleteEnvironmentInput!): OperationResult! deleteMcpServer(appId: ULID!, serverId: ULID!): OperationResult! deleteOwnedSkill(appId: ULID!, skillId: ULID!): OperationResult! deleteVendorCredential(input: DeleteVendorCredentialInput!): OperationResult! - deployApp(input: DeployAppInput!): AppDeploymentRun! importAgentPackage(input: ImportAgentPackageInput!): AgentPackageImportResult! onboardingBootstrap(input: BootstrapOnboardingInput!): OnboardingStatus! prewarmAgentSession(appId: ULID!, sessionId: ULID!): SessionRuntimePrewarmAck! @@ -1233,15 +1152,12 @@ type Query { agentSessionList(agentId: ULID!, appId: ULID!, archived: Boolean, beforeCursor: String, limit: Int, participantOnly: Boolean, type: SessionType): SessionConnection! agentSessionRetrieve(appId: ULID!, sessionId: ULID!): AgentSessionRetrieve! appCostCard(appId: ULID!, range: CostRange!, runPurposes: [CostRunPurpose!]): AppCostCard! - appDeploymentRunList(appId: ULID!, limit: Int): [AppDeploymentRun!]! - appDeploymentStatus(appId: ULID!): AppDeploymentRun appEnvironmentList(appId: ULID!): [EnvironmentSummary!]! appInfo: AppInfo! appList(organizationId: ULID!): [App!]! appOverview(agentLimit: Int, appId: ULID!, credentialLimit: Int): AppOverview! appSkillList(appId: ULID!): [SkillSummary!]! availableAgentModels(appId: ULID!, currentModelId: String, currentVendorId: String, runtimeId: String!): [ResolvedModelEntry!]! - boundCapabilityRunProvenance(appId: ULID!, runId: ULID!): BoundCapabilityRunProvenance controlPlaneOverview(agentLimit: Int, appLimit: Int, credentialLimit: Int): ControlPlaneOverview! environment(appId: ULID!, environmentId: ULID!): EnvironmentDetail! exportAgentPackage(agentId: ULID!, appId: ULID!): AgentPackageExport! diff --git a/apps/api/src/adapters/graphql/schema/app-schema.ts b/apps/api/src/adapters/graphql/schema/app-schema.ts index 76625b02..b22cc75f 100644 --- a/apps/api/src/adapters/graphql/schema/app-schema.ts +++ b/apps/api/src/adapters/graphql/schema/app-schema.ts @@ -1,28 +1,8 @@ export const appSchema = /* GraphQL */ ` - enum AppOverviewBoundAgentExposure { - public_thread - } - enum AppOverviewProviderCredentialStatus { configured } - enum AppDeploymentRunStatus { - activating - building - failed - preparing - queued - submitted - submitting - success - } - - enum AppDeploymentTargetKind { - cloudflare_pages - cloudflare_worker - } - type App { createdAt: String! defaultEnvironmentId: ULID @@ -31,36 +11,6 @@ export const appSchema = /* GraphQL */ ` ownerAccountId: ULID! } - type AppDeploymentRun { - appId: ULID! - createdAt: String! - deploymentId: ULID! - errorCode: String - errorMessage: String - id: ULID! - liveUrl: String - plannedUrl: String! - sourceBranch: String! - sourceCommitSha: String! - status: AppDeploymentRunStatus! - targetKind: AppDeploymentTargetKind - updatedAt: String! - } - - type AppDeployment { - appId: ULID! - createdAt: String! - defaultBranch: String! - id: ULID! - latestRun: AppDeploymentRun - liveUrl: String - plannedUrl: String! - repoName: String! - repoOwner: String! - repoUrl: String! - updatedAt: String! - } - type AppOverviewAgent { appId: ULID! description: String @@ -80,13 +30,6 @@ export const appSchema = /* GraphQL */ ` limit: Int! } - type AppOverviewBoundAgent { - agentId: ULID! - envVar: String! - expose: AppOverviewBoundAgentExposure! - name: String! - } - type AppOverviewProviderCredential { appId: ULID! hasCustomApiBase: Boolean! @@ -115,8 +58,6 @@ export const appSchema = /* GraphQL */ ` type AppOverview { agents: AppOverviewAgentList! app: App! - boundAgents: [AppOverviewBoundAgent!]! - deployment: AppDeployment providerCredentials: AppOverviewProviderCredentialList! } @@ -136,16 +77,6 @@ export const appSchema = /* GraphQL */ ` organizationId: ULID! } - input DeployAppInput { - appId: ULID! - configPath: String - repoUrl: String! - } - - input DeleteAppDeploymentInput { - appId: ULID! - } - input RenameAppInput { appId: ULID! name: String! diff --git a/apps/api/src/adapters/graphql/schema/session-schema.ts b/apps/api/src/adapters/graphql/schema/session-schema.ts index 72d67df1..d7dc14ff 100644 --- a/apps/api/src/adapters/graphql/schema/session-schema.ts +++ b/apps/api/src/adapters/graphql/schema/session-schema.ts @@ -138,16 +138,6 @@ export const sessionSchema = /* GraphQL */ ` updatedAt: String! } - type BoundCapabilityRunProvenance { - agentId: ULID! - appId: ULID! - bindingEnv: String! - bindingName: String! - deploymentId: ULID! - deploymentRunId: ULID! - runId: ULID! - } - enum AgentSessionEventType { ${graphQLEnumValues(AGENT_SESSION_EVENT_TYPES)} } diff --git a/apps/api/src/adapters/http/request-logging.middleware.ts b/apps/api/src/adapters/http/request-logging.middleware.ts index 8ab6921c..2e30be79 100644 --- a/apps/api/src/adapters/http/request-logging.middleware.ts +++ b/apps/api/src/adapters/http/request-logging.middleware.ts @@ -1,6 +1,5 @@ import type { MiddlewareHandler } from "hono"; -import { APP_AGENT_BOUND_PATH_PREFIX } from "../../modules/public-api/app-agent-capability"; import { createApiWideEvent, createRequestLogContext, @@ -9,30 +8,6 @@ import { } from "../../platform/cloudflare/logger"; import type { ApiGatewayEnvironment } from "../../platform/cloudflare/worker-types"; -/** - * A bound capability URL is a bearer secret carried in the path. Request logs - * keep the route shape (`/api/v1/bound/:token/...`) and never the token. - */ -export function redactRequestLogPath(pathname: string): string { - const prefix = `${APP_AGENT_BOUND_PATH_PREFIX}/`; - - if (!pathname.startsWith(prefix)) { - return pathname; - } - - const remainder = pathname.slice(prefix.length); - const nextSlash = remainder.indexOf("/"); - - return `${APP_AGENT_BOUND_PATH_PREFIX}/:token${nextSlash === -1 ? "" : remainder.slice(nextSlash)}`; -} - -function createRedactedRequestLogContext(request: Request) { - return { - ...createRequestLogContext(request), - path: redactRequestLogPath(new URL(request.url).pathname), - }; -} - export function requestLoggingMiddleware(): MiddlewareHandler { return async (c, next) => runWithRequestLogContext(c.req.raw, async () => { @@ -40,7 +15,7 @@ export function requestLoggingMiddleware(): MiddlewareHandler; interface PublicApiThreadOperation { - caller: PublicApiCaller; + caller: AuthenticatedViewer; } -type RouteValue = T | (() => T); - -/** - * How a route resolves its caller. The default reads the owner Access Token - * bearer header; bound capability routes resolve the deployment identity from - * the capability token in their path instead. - */ -export type PublicApiCallerResolver = (c: PublicApiRouteContext) => Promise; - -export interface PublicApiCallerOptions { - /** Normalized idempotency route; defaults to the request pathname. */ - idempotencyRoute?: string | undefined; - resolveCaller?: PublicApiCallerResolver | undefined; +interface PublicApiTokenOperation { + caller: PersonalAccessTokenCaller; } +type RouteValue = T | (() => T); + interface PublicApiJsonErrorResponse { body: { error: { @@ -79,29 +59,16 @@ function resolveRequiredRouteValue(value: RouteValue): T { return typeof value === "function" ? (value as () => T)() : value; } -/** Rate-limit bucket for a caller: Access Tokens by token id, capabilities by App + Agent. */ -function publicApiRateLimitKey(caller: PublicApiCaller): string { - return caller.kind === "access_token" - ? caller.tokenId - : deploymentCapabilityRateLimitKey(caller.capability); -} - -/** - * Idempotency subject for a caller. A deployment capability shares one subject - * across the revisions of its Deployment, so a retry after a redeploy replays. - */ -function publicApiIdempotencySubjectId(caller: PublicApiCaller): PlatformId { - return caller.kind === "access_token" ? caller.tokenId : caller.capability.deploymentId; -} - -async function requireAccessTokenCaller(c: PublicApiRouteContext): Promise { +async function requireAccessTokenCaller( + c: PublicApiRouteContext, +): Promise { const token = readBearerToken(c.req.raw); if (!isTruthy(token)) { throw publicUnauthenticated(); } - const caller = await authenticatePublicApiCaller(c.env.DB, token); + const caller = await authenticatePersonalAccessToken(c.env.DB, token); if (!caller) { throw publicUnauthenticated("Access Token is invalid or revoked."); @@ -110,57 +77,14 @@ async function requireAccessTokenCaller(c: PublicApiRouteContext): Promise { - const token = readPublicApiBearerToken(c.req.raw); - - if (!isTruthy(token)) { - throw publicUnauthenticated("A valid Access Token is required."); - } - - const caller = await authenticatePublicApiCaller(c.env.DB, token); - - if (!caller) { - throw publicUnauthenticated("Access Token is invalid or revoked."); - } - - return caller; -} - -async function requireRateLimitedCaller( +async function requireRateLimitedAccessTokenCaller( c: PublicApiRouteContext, - resolveCaller: PublicApiCallerResolver, -): Promise { - const caller = await resolveCaller(c); - await enforcePublicApiRateLimit(c.env.DB, publicApiRateLimitKey(caller)); +): Promise { + const caller = await requireAccessTokenCaller(c); + await enforcePublicApiRateLimit(c.env.DB, caller.tokenId); return caller; } -/** - * Resolve the deployment-scoped identity carried by a bound capability URL - * (`/bound/:token/...`). Verification, Agent servability, Deployment authority, - * and owner resolution all happen before any route logic runs. - */ -export async function requireDeploymentCapabilityCaller( - c: PublicApiRouteContext, -): Promise { - const admission = await admitDeploymentCapability(c.env, c.req.param("token") ?? "", Date.now()); - - return toDeploymentCapabilityCaller(admission); -} - -/** - * Idempotency reservations are keyed by route. A bound capability URL embeds - * the per-revision token in its path, so normalize it away: the same - * Idempotency-Key from a redeployed Worker must replay the original response - * instead of failing as a different request. - */ -export function deploymentCapabilityIdempotencyRoute(c: PublicApiRouteContext): string { - const token = c.req.param("token") ?? ""; - const pathname = new URL(c.req.url).pathname; - - return token.length === 0 ? pathname : pathname.replace(`/bound/${token}`, "/bound/:token"); -} - function errorHeaders(error: PublicApiError): HeadersInit { if (error.retryAfterSeconds === null) { return {}; @@ -212,12 +136,6 @@ function toErrorResponseDetails(error: unknown): PublicApiJsonErrorResponse { return toErrorResponseDetails(publicReadinessBlocked(error.message)); } - // A deployment capability's Run insert repeats the Deployment authority - // condition; losing that race means the capability was revoked mid-request. - if (error instanceof SessionRunCreationGuardRejectedError) { - return toErrorResponseDetails(publicAgentNotExposed(DEPLOYMENT_CAPABILITY_REVOKED_MESSAGE)); - } - if (error instanceof SyntaxError) { return toErrorResponseDetails(publicInvalidJson()); } @@ -280,7 +198,6 @@ async function runPublicApiIdempotentJson( c: PublicApiRouteContext, input: { bodyHash: string | null; - idempotencyRoute?: string | undefined; idempotencySubjectId: PlatformId; beforeOperation?: (() => Promise) | undefined; operation: (idempotencyKey: string | null) => Promise; @@ -296,7 +213,7 @@ async function runPublicApiIdempotentJson( return Response.json(await input.operation(null), { status: input.status }); } - const route = input.idempotencyRoute ?? new URL(c.req.url).pathname; + const route = new URL(c.req.url).pathname; let reservation = await beginPublicApiIdempotency(c.env.DB, { bodyHash: input.bodyHash, idempotencyKey, @@ -420,16 +337,12 @@ async function runPublicApiIdempotentJson( export async function runPublicApiAuthenticatedJson( c: PublicApiRouteContext, - operation: (caller: PublicApiCaller) => Promise, + operation: (caller: AuthenticatedViewer) => Promise, status = 200, - options: PublicApiCallerOptions = {}, ): Promise { try { - const caller = await requireRateLimitedCaller( - c, - options.resolveCaller ?? requireAccessTokenCaller, - ); - return Response.json(await operation(caller), { status }); + const caller = await requireRateLimitedAccessTokenCaller(c); + return Response.json(await operation(caller.viewer), { status }); } catch (error) { return toErrorResponse(error); } @@ -437,15 +350,11 @@ export async function runPublicApiAuthenticatedJson( export async function runPublicApiAuthenticatedResponse( c: PublicApiRouteContext, - operation: (caller: PublicApiCaller) => Promise, - options: PublicApiCallerOptions = {}, + operation: (caller: AuthenticatedViewer) => Promise, ): Promise { try { - const caller = await requireRateLimitedCaller( - c, - options.resolveCaller ?? requireAccessTokenCaller, - ); - return await operation(caller); + const caller = await requireRateLimitedAccessTokenCaller(c); + return await operation(caller.viewer); } catch (error) { return toErrorResponse(error); } @@ -453,7 +362,7 @@ export async function runPublicApiAuthenticatedResponse( export async function runPublicApiSessionMutation( c: PublicApiRouteContext, - input: PublicApiCallerOptions & { + input: { bodyHash?: (prepared: Prepared) => string | null; operation: ( input: PublicApiThreadOperation & { @@ -467,22 +376,20 @@ export async function runPublicApiSessionMutation( }, ): Promise { try { - const caller = await (input.resolveCaller ?? requireAccessTokenCaller)(c); + const caller = await requireAccessTokenCaller(c); const threadId = resolveRequiredRouteValue(input.threadId); - const operationInput: PublicApiThreadOperation = { caller }; + const operationInput: PublicApiThreadOperation = { caller: caller.viewer }; const prepared = input.prepare ? await input.prepare(operationInput) : (undefined as Prepared); const status = input.status ?? 200; const operation = async (_idempotencyKey: string | null) => input.operation({ ...operationInput, prepared, threadId }); - const beforeOperation = () => - enforcePublicApiRateLimit(c.env.DB, publicApiRateLimitKey(caller)); + const beforeOperation = () => enforcePublicApiRateLimit(c.env.DB, caller.tokenId); if (input.bodyHash) { return await runPublicApiIdempotentJson(c, { bodyHash: input.bodyHash(prepared), beforeOperation, - idempotencyRoute: input.idempotencyRoute, - idempotencySubjectId: publicApiIdempotencySubjectId(caller), + idempotencySubjectId: caller.tokenId, operation, status, }); @@ -497,18 +404,18 @@ export async function runPublicApiSessionMutation( export async function runPublicApiThreadReadJson( c: PublicApiRouteContext, - input: PublicApiCallerOptions & { + input: { operation: (input: PublicApiThreadOperation & { threadId: PublicThreadId }) => Promise; status?: number | undefined; threadId: RouteValue; }, ): Promise { try { - const caller = await requireRateLimitedCaller(c, input.resolveCaller ?? requirePublicApiCaller); + const caller = await requireRateLimitedAccessTokenCaller(c); const threadId = resolveRequiredRouteValue(input.threadId); const status = input.status ?? 200; - return Response.json(await input.operation({ caller, threadId }), { status }); + return Response.json(await input.operation({ caller: caller.viewer, threadId }), { status }); } catch (error) { return toErrorResponse(error); } @@ -516,7 +423,7 @@ export async function runPublicApiThreadReadJson( export async function runPublicApiThreadReadResponse( c: PublicApiRouteContext, - input: PublicApiCallerOptions & { + input: { operation: ( input: PublicApiThreadOperation & { threadId: PublicThreadId }, ) => Promise; @@ -524,10 +431,10 @@ export async function runPublicApiThreadReadResponse( }, ): Promise { try { - const caller = await requireRateLimitedCaller(c, input.resolveCaller ?? requirePublicApiCaller); + const caller = await requireRateLimitedAccessTokenCaller(c); const threadId = resolveRequiredRouteValue(input.threadId); - return await input.operation({ caller, threadId }); + return await input.operation({ caller: caller.viewer, threadId }); } catch (error) { return toErrorResponse(error); } @@ -535,20 +442,19 @@ export async function runPublicApiThreadReadResponse( export async function runPublicApiThreadMutation( c: PublicApiRouteContext, - input: PublicApiCallerOptions & { - /** The target Agent; a resolver may derive it from the admitted caller (bound capability). */ - agentId: AgentId | ((caller: PublicApiCaller) => AgentId); + input: { + agentId: RouteValue; bodyHash?: (prepared: Prepared) => string | null; operation: ( - input: PublicApiThreadOperation & { + input: PublicApiTokenOperation & { agentId: AgentId; idempotencyKey: string | null; prepared: Prepared; }, ) => Promise; - prepare?: (input: PublicApiThreadOperation) => Promise; + prepare?: (input: PublicApiTokenOperation) => Promise; recover?: ( - input: PublicApiThreadOperation & { + input: PublicApiTokenOperation & { agentId: AgentId; idempotencyKey: string; prepared: Prepared; @@ -558,9 +464,9 @@ export async function runPublicApiThreadMutation( }, ): Promise { try { - const caller = await (input.resolveCaller ?? requirePublicApiCaller)(c); - const agentId = typeof input.agentId === "function" ? input.agentId(caller) : input.agentId; - const operationInput: PublicApiThreadOperation = { caller }; + const caller = await requireAccessTokenCaller(c); + const agentId = resolveRequiredRouteValue(input.agentId); + const operationInput: PublicApiTokenOperation = { caller }; const prepared = input.prepare ? await input.prepare(operationInput) : (undefined as Prepared); const status = input.status ?? 200; const operation = async (idempotencyKey: string | null) => @@ -569,15 +475,13 @@ export async function runPublicApiThreadMutation( ? async (idempotencyKey: string) => input.recover?.({ ...operationInput, agentId, idempotencyKey, prepared }) ?? null : undefined; - const beforeOperation = () => - enforcePublicApiRateLimit(c.env.DB, publicApiRateLimitKey(caller)); + const beforeOperation = () => enforcePublicApiRateLimit(c.env.DB, caller.tokenId); if (input.bodyHash) { return await runPublicApiIdempotentJson(c, { bodyHash: input.bodyHash(prepared), beforeOperation, - idempotencyRoute: input.idempotencyRoute, - idempotencySubjectId: publicApiIdempotencySubjectId(caller), + idempotencySubjectId: caller.tokenId, operation, persistOperationErrors: true, recover, diff --git a/apps/api/src/adapters/http/routes/public-api-route.ts b/apps/api/src/adapters/http/routes/public-api-route.ts index ae03221a..576f914e 100644 --- a/apps/api/src/adapters/http/routes/public-api-route.ts +++ b/apps/api/src/adapters/http/routes/public-api-route.ts @@ -1,22 +1,15 @@ import { PUBLIC_API_VERSION_PREFIX } from "@mosoo/contracts/public-api"; -import type { AgentId, PublicThreadId } from "@mosoo/id"; +import type { PublicThreadId } from "@mosoo/id"; import { Hono } from "hono"; import type { Context } from "hono"; -import type { PublicApiCaller } from "../../../modules/auth/application/public-api-caller.service"; -import { parseBoundAgentCallBody } from "../../../modules/public-api/app-agent-bound-call"; -import { renderBoundAgentCallError } from "../../../modules/public-api/app-agent-bound-errors"; +import type { AuthenticatedViewer } from "../../../modules/auth/application/viewer-auth.service"; import { publicInvalidRequest } from "../../../modules/public-api/public-api-errors"; -import { - hashPublicApiIdempotencyBody, - readPublicApiIdempotencyKey, -} from "../../../modules/public-api/public-api-idempotency.service"; +import { hashPublicApiIdempotencyBody } from "../../../modules/public-api/public-api-idempotency.service"; import { listAgentApiEndpointThreads } from "../../../modules/public-api/public-thread-session-query.service"; import type { ApiGatewayEnvironment } from "../../../platform/cloudflare/worker-types"; import { createPublicApiOpenApiDocument } from "./public-api-openapi"; import { - deploymentCapabilityIdempotencyRoute, - requireDeploymentCapabilityCaller, runPublicApiAuthenticatedJson, runPublicApiAuthenticatedResponse, runPublicApiSessionMutation, @@ -24,7 +17,6 @@ import { runPublicApiThreadReadJson, runPublicApiThreadReadResponse, } from "./public-api-route-support"; -import type { PublicApiCallerOptions } from "./public-api-route-support"; import { parseFileContentDisposition, parseOptionalBoolean, @@ -32,7 +24,6 @@ import { parseFileIdParam, parseThreadIdParam, parseThreadEventsLimit, - readBoundAgentCallRequestBody, readCreateThreadRequest, readSendEventsRequest, } from "./public-thread-api-request"; @@ -44,54 +35,6 @@ interface PublicAgentFileUploadRequest { } type PublicThreadFileService = Awaited>; -/** - * The injected capability URL path within `/api/v1`; the full public prefix is - * `APP_AGENT_BOUND_PATH_PREFIX` in `app-agent-capability.ts`. - */ -const BOUND_CAPABILITY_ROUTE_BASE = "/bound/:token"; - -/** - * One Public Thread surface, two ways to address it. Access Token routes take - * the Agent from the path and the caller from the bearer header; bound - * capability routes mount the same operations under the injected capability - * URL, take the Agent from the verified claims, and resolve the deployment- - * scoped caller from the token in the path. - */ -interface PublicThreadRouteScope { - /** Path prefix for Agent-addressed operations (create thread, upload file, list threads). */ - agentBase: string; - /** Resolves the target Agent after the caller is admitted (path param or capability claim). */ - agentId: (c: PublicApiRouteContext) => (caller: PublicApiCaller) => AgentId; - /** Path prefix for Thread- and file-addressed operations. */ - base: string; - options: (c: PublicApiRouteContext) => PublicApiCallerOptions; -} - -function deploymentCapabilityAgentId(caller: PublicApiCaller): AgentId { - if (caller.kind !== "deployment_capability") { - throw new Error("Bound capability routes require a deployment capability caller."); - } - - return caller.capability.agentId; -} - -const ACCESS_TOKEN_SCOPE: PublicThreadRouteScope = { - agentBase: "/agents/:agentId", - agentId: (c) => () => parseAgentIdParam(c.req.param("agentId") ?? ""), - base: "", - options: () => ({}), -}; - -const BOUND_CAPABILITY_SCOPE: PublicThreadRouteScope = { - agentBase: BOUND_CAPABILITY_ROUTE_BASE, - agentId: () => deploymentCapabilityAgentId, - base: BOUND_CAPABILITY_ROUTE_BASE, - options: (c) => ({ - idempotencyRoute: deploymentCapabilityIdempotencyRoute(c), - resolveCaller: requireDeploymentCapabilityCaller, - }), -}; - async function loadPublicThreadCommandService() { return import("../../../modules/public-api/public-thread-api-command.service"); } @@ -104,15 +47,10 @@ async function loadPublicThreadFileService() { return import("../../../modules/public-api/public-thread-file-api.service"); } -async function loadBoundAgentAskService() { - return import("../../../modules/public-api/app-agent-bound-ask.service"); -} - async function runPublicThreadFileRoute( c: PublicApiRouteContext, - scope: PublicThreadRouteScope, operation: (input: { - caller: PublicApiCaller; + caller: AuthenticatedViewer; service: PublicThreadFileService; threadId: PublicThreadId; }) => Promise, @@ -127,7 +65,6 @@ async function runPublicThreadFileRoute( threadId: parseThreadIdParam(c.req.param("threadId") ?? ""), }), status, - scope.options(c), ); } @@ -154,19 +91,10 @@ async function readPublicAgentFileUploadRequest( return { file }; } -/** - * Thread lifecycle, observation, and file routes shared by both scopes. The - * destructive owner operations (archive, unarchive, delete) stay Access Token - * only — see `registerAccessTokenOnlyRoutes`. - */ -function registerPublicThreadRoutes( - v1: Hono, - scope: PublicThreadRouteScope, -): void { - v1.post(`${scope.agentBase}/threads`, async (c) => { +function registerPublicThreadRoutes(v1: Hono): void { + v1.post("/agents/:agentId/threads", async (c) => { return runPublicApiThreadMutation(c, { - ...scope.options(c), - agentId: scope.agentId(c), + agentId: () => parseAgentIdParam(c.req.param("agentId") ?? ""), bodyHash: (prepared) => prepared.bodyHash, operation: async ({ agentId, caller, idempotencyKey, prepared }) => { const { createPublicThread } = await loadPublicThreadService(); @@ -203,38 +131,32 @@ function registerPublicThreadRoutes( }); }); - v1.post(`${scope.agentBase}/files`, async (c) => - runPublicApiThreadMutation(c, { - ...scope.options(c), - agentId: scope.agentId(c), - operation: async ({ agentId, caller, prepared }) => { + v1.post("/agents/:agentId/files", async (c) => + runPublicApiAuthenticatedJson( + c, + async (caller) => { const service = await loadPublicThreadFileService(); + const prepared = await readPublicAgentFileUploadRequest(c); return service.createPublicAgentFile(c.env, caller, { - agentId, + agentId: parseAgentIdParam(c.req.param("agentId") ?? ""), file: prepared.file, }); }, - prepare: async () => readPublicAgentFileUploadRequest(c), - status: 201, - }), + 201, + ), ); - v1.get(`${scope.agentBase}/threads`, async (c) => - runPublicApiAuthenticatedJson( - c, - async (caller) => - listAgentApiEndpointThreads(c.env.DB, caller, { - agentId: scope.agentId(c)(caller), - archived: parseOptionalBoolean(c.req.query("archived")), - }), - 200, - scope.options(c), + v1.get("/agents/:agentId/threads", async (c) => + runPublicApiAuthenticatedJson(c, async (caller) => + listAgentApiEndpointThreads(c.env.DB, caller, { + agentId: parseAgentIdParam(c.req.param("agentId") ?? ""), + archived: parseOptionalBoolean(c.req.query("archived")), + }), ), ); - v1.get(`${scope.base}/threads/:threadId`, async (c) => + v1.get("/threads/:threadId", async (c) => runPublicApiThreadReadJson(c, { - ...scope.options(c), operation: async ({ caller, threadId }) => { const { retrievePublicThread } = await loadPublicThreadService(); return retrievePublicThread({ @@ -247,9 +169,8 @@ function registerPublicThreadRoutes( }), ); - v1.get(`${scope.base}/threads/:threadId/events`, async (c) => + v1.get("/threads/:threadId/events", async (c) => runPublicApiThreadReadJson(c, { - ...scope.options(c), operation: async ({ caller, threadId }) => { const { listPublicThreadEvents } = await loadPublicThreadService(); return listPublicThreadEvents({ @@ -263,9 +184,8 @@ function registerPublicThreadRoutes( }), ); - v1.get(`${scope.base}/threads/:threadId/events/stream`, async (c) => + v1.get("/threads/:threadId/events/stream", async (c) => runPublicApiThreadReadResponse(c, { - ...scope.options(c), operation: async ({ caller, threadId }) => { const { createPublicThreadEventStream } = await loadPublicThreadService(); const stream = await createPublicThreadEventStream({ @@ -289,9 +209,8 @@ function registerPublicThreadRoutes( }), ); - v1.post(`${scope.base}/threads/:threadId/events`, async (c) => { + v1.post("/threads/:threadId/events", async (c) => { return runPublicApiSessionMutation(c, { - ...scope.options(c), bodyHash: (prepared) => prepared.bodyHash, operation: async ({ caller, prepared, threadId }) => { const { sendPublicThreadSessionEvents } = await loadPublicThreadCommandService(); @@ -315,40 +234,29 @@ function registerPublicThreadRoutes( }); }); - v1.get(`${scope.base}/threads/:threadId/files`, async (c) => - runPublicThreadFileRoute(c, scope, async ({ caller, service, threadId }) => + v1.get("/threads/:threadId/files", async (c) => + runPublicThreadFileRoute(c, async ({ caller, service, threadId }) => service.listPublicThreadFiles(c.env, caller, threadId), ), ); - v1.get(`${scope.base}/files/:fileId/content`, async (c) => - runPublicApiAuthenticatedResponse( - c, - async (caller) => { - const service = await loadPublicThreadFileService(); - return service.downloadPublicThreadFileContent(c.env, caller, { - disposition: parseFileContentDisposition(c.req.query("disposition")), - fileId: parseFileIdParam(c.req.param("fileId")), - }); - }, - scope.options(c), - ), + v1.get("/files/:fileId/content", async (c) => + runPublicApiAuthenticatedResponse(c, async (caller) => { + const service = await loadPublicThreadFileService(); + return service.downloadPublicThreadFileContent(c.env, caller, { + disposition: parseFileContentDisposition(c.req.query("disposition")), + fileId: parseFileIdParam(c.req.param("fileId")), + }); + }), ); - v1.get(`${scope.base}/files/:fileId`, async (c) => - runPublicApiAuthenticatedJson( - c, - async (caller) => { - const service = await loadPublicThreadFileService(); - return service.retrievePublicFile(c.env, caller, parseFileIdParam(c.req.param("fileId"))); - }, - 200, - scope.options(c), - ), + v1.get("/files/:fileId", async (c) => + runPublicApiAuthenticatedJson(c, async (caller) => { + const service = await loadPublicThreadFileService(); + return service.retrievePublicFile(c.env, caller, parseFileIdParam(c.req.param("fileId"))); + }), ); -} -function registerAccessTokenOnlyRoutes(v1: Hono): void { v1.delete("/files/:fileId", async (c) => runPublicApiAuthenticatedJson(c, async (caller) => { const service = await loadPublicThreadFileService(); @@ -403,7 +311,7 @@ function registerAccessTokenOnlyRoutes(v1: Hono): void { }); v1.delete("/threads/:threadId/files/:fileId", async (c) => - runPublicThreadFileRoute(c, ACCESS_TOKEN_SCOPE, async ({ caller, service, threadId }) => { + runPublicThreadFileRoute(c, async ({ caller, service, threadId }) => { await service.deletePublicThreadFile(c.env, caller, { fileId: parseFileIdParam(c.req.param("fileId")), threadId, @@ -418,35 +326,7 @@ export function registerPublicApiRoute(app: Hono) { v1.get("/openapi.json", (c) => c.json(createPublicApiOpenApiDocument(new URL(c.req.url).origin))); - // The blocking bound-agent ask: POST the injected capability URL itself. - v1.post(BOUND_CAPABILITY_ROUTE_BASE, async (c) => { - try { - const body = await readBoundAgentCallRequestBody(c); - const { createBoundAgentThreadAndWait } = await loadBoundAgentAskService(); - const result = await createBoundAgentThreadAndWait({ - bindings: c.env, - executionContext: c.executionCtx, - idempotencyKey: readPublicApiIdempotencyKey(c.req.raw), - input: parseBoundAgentCallBody(body), - requestUrl: c.req.url, - token: c.req.param("token") ?? "", - }); - - return Response.json(result, { status: 200 }); - } catch (error) { - const rendered = renderBoundAgentCallError(error); - return Response.json(rendered.body, { status: rendered.status }); - } - }); - - // The Public Thread and file workflow, addressed by the same capability URL: - // upload attachments, create and continue Threads, observe Runs, and download - // artifacts — all scoped to the App, Agent binding, and Deployment that - // minted the capability, without any owner Access Token. - registerPublicThreadRoutes(v1, BOUND_CAPABILITY_SCOPE); - - registerPublicThreadRoutes(v1, ACCESS_TOKEN_SCOPE); - registerAccessTokenOnlyRoutes(v1); + registerPublicThreadRoutes(v1); app.route(PUBLIC_API_VERSION_PREFIX, v1); } diff --git a/apps/api/src/adapters/http/routes/public-thread-api-request.ts b/apps/api/src/adapters/http/routes/public-thread-api-request.ts index 5c820246..163c1d29 100644 --- a/apps/api/src/adapters/http/routes/public-thread-api-request.ts +++ b/apps/api/src/adapters/http/routes/public-thread-api-request.ts @@ -112,10 +112,6 @@ async function readRequestTextWithLimit(request: Request, maxBytes: number): Pro } } -async function readJsonBodyWithLimit(c: RawJsonRequestContext, maxBytes: number): Promise { - return JSON.parse(await readRequestTextWithLimit(c.req.raw, maxBytes)); -} - async function readOptionalJsonBodyWithLimit( c: RawJsonRequestContext, maxBytes: number, @@ -451,16 +447,6 @@ export async function readSendEventsRequest( }; } -/** - * Read and JSON-parse the bound-agent call body under the shared public-API - * body-size cap. The bound endpoint is keyless and internet-facing, so it must - * refuse oversized bodies the same way every PAT thread route does instead of - * buffering an unbounded request into the isolate. - */ -export async function readBoundAgentCallRequestBody(c: RawJsonRequestContext): Promise { - return readJsonBodyWithLimit(c, PUBLIC_THREAD_JSON_BODY_MAX_BYTES); -} - export async function readCreateThreadRequest( c: RawJsonRequestContext, ): Promise { diff --git a/apps/api/src/modules/api-command/application/api-command-enqueue.ts b/apps/api/src/modules/api-command/application/api-command-enqueue.ts index 50ae37a0..802f6456 100644 --- a/apps/api/src/modules/api-command/application/api-command-enqueue.ts +++ b/apps/api/src/modules/api-command/application/api-command-enqueue.ts @@ -1,32 +1,12 @@ -import type { AppDeploymentRunId } from "@mosoo/id"; - import type { ApiBindings } from "../../../platform/cloudflare/worker-types"; import { admitApiCommand, enqueueApiCommand } from "./api-command-ledger"; import type { ApiCommandAdmission, EnqueueApiCommandInput } from "./api-command-ledger"; import type { - AppDeploymentRunDispatchCommandPayload, CostLedgerReconciliationCommandPayload, ScheduledMaintenanceCommandPayload, SessionRunDispatchCommandPayload, } from "./api-command-payload"; -export const APP_DEPLOYMENT_RUN_DISPATCH_DEDUPE_PREFIX = "app_deployment_run_dispatch:" as const; - -export function createAppDeploymentRunDispatchDedupeKey(runId: AppDeploymentRunId): string { - return `${APP_DEPLOYMENT_RUN_DISPATCH_DEDUPE_PREFIX}${runId}`; -} - -export async function enqueueAppDeploymentRunDispatchCommand( - bindings: Pick, - payload: AppDeploymentRunDispatchCommandPayload, -): Promise { - await enqueueApiCommand(bindings, { - dedupeKey: createAppDeploymentRunDispatchDedupeKey(payload.appDeploymentRunId), - kind: "app_deployment_run_dispatch", - payload, - }); -} - export async function enqueueCostLedgerReconciliationCommand( bindings: Pick, payload: CostLedgerReconciliationCommandPayload, diff --git a/apps/api/src/modules/api-command/application/api-command-ledger.ts b/apps/api/src/modules/api-command/application/api-command-ledger.ts index 4cb37e4f..e44f271d 100644 --- a/apps/api/src/modules/api-command/application/api-command-ledger.ts +++ b/apps/api/src/modules/api-command/application/api-command-ledger.ts @@ -38,7 +38,6 @@ export interface PreparedApiCommand { export interface ApiCommandClaim { attemptCount: number; commandId: ApiCommandId; - dedupeKey: string; kind: ApiCommandKind; payloadJson: string; } @@ -327,7 +326,6 @@ export async function claimApiCommand(input: { .returning({ attemptCount: apiCommandsTable.attemptCount, commandId: apiCommandsTable.id, - dedupeKey: apiCommandsTable.dedupeKey, kind: apiCommandsTable.kind, payloadJson: apiCommandsTable.payloadJson, }) diff --git a/apps/api/src/modules/api-command/application/api-command-payload.ts b/apps/api/src/modules/api-command/application/api-command-payload.ts index 80fe461c..b8dd9885 100644 --- a/apps/api/src/modules/api-command/application/api-command-payload.ts +++ b/apps/api/src/modules/api-command/application/api-command-payload.ts @@ -1,13 +1,6 @@ import type { ApiCommandKind } from "@mosoo/db"; import { parsePlatformId } from "@mosoo/id"; -import type { - AccountId, - AppDeploymentRunId, - FileId, - AppId, - SessionId, - SessionRunId, -} from "@mosoo/id"; +import type { AccountId, FileId, AppId, SessionId, SessionRunId } from "@mosoo/id"; import type { AuthenticatedViewer } from "../../auth/application/viewer-auth.service"; import type { @@ -16,7 +9,6 @@ import type { } from "../../cost/application/cost-ledger-reconciliation.service"; type ApiCommandPayload = - | AppDeploymentRunDispatchCommandPayload | CostLedgerReconciliationCommandPayload | EnvironmentPackageArtifactBuildCommandPayload | ScheduledMaintenanceCommandPayload @@ -34,10 +26,6 @@ export interface CostLedgerReconciliationCommandPayload { scheduledTime: number; } -export interface AppDeploymentRunDispatchCommandPayload { - appDeploymentRunId: AppDeploymentRunId; -} - export interface EnvironmentPackageArtifactBuildCommandPayload { appId: AppId; artifactAbi: string; @@ -228,19 +216,6 @@ function parseCostLedgerReconciliationPayload( }; } -function parseAppDeploymentRunDispatchPayload( - value: unknown, -): AppDeploymentRunDispatchCommandPayload { - const record = requireRecord(value, "app_deployment_run_dispatch payload"); - - return { - appDeploymentRunId: parsePlatformId( - record["appDeploymentRunId"], - "app_deployment_run_dispatch payload.appDeploymentRunId", - ), - }; -} - function parseEnvironmentPackageArtifactBuildPayload( value: unknown, ): EnvironmentPackageArtifactBuildCommandPayload { @@ -288,9 +263,6 @@ export function parseApiCommandPayload( const parsed = parsePayloadJson(payloadJson); switch (kind) { - case "app_deployment_run_dispatch": { - return parseAppDeploymentRunDispatchPayload(parsed); - } case "cost_ledger_reconciliation": { return parseCostLedgerReconciliationPayload(parsed); } diff --git a/apps/api/src/modules/api-command/application/api-command-policy.ts b/apps/api/src/modules/api-command/application/api-command-policy.ts deleted file mode 100644 index 29ddd098..00000000 --- a/apps/api/src/modules/api-command/application/api-command-policy.ts +++ /dev/null @@ -1,17 +0,0 @@ -export const APP_DEPLOYMENT_RUN_DISPATCH_MAX_ATTEMPTS = 3; - -export const APP_DEPLOYMENT_RUN_DISPATCH_RETRY_EXHAUSTED_CODE = - "deployment_dispatch_retry_exhausted"; - -export function createAppDeploymentDispatchRetryExhaustedMessage(input: { - attemptCount: number; - lastErrorMessage: string | null; -}): string { - const detail = input.lastErrorMessage?.trim(); - - if (detail !== undefined && detail.length > 0) { - return `Deployment dispatch failed after ${input.attemptCount} attempts: ${detail}`; - } - - return `Deployment dispatch failed after ${input.attemptCount} attempts.`; -} diff --git a/apps/api/src/modules/api-command/application/api-command-processor.ts b/apps/api/src/modules/api-command/application/api-command-processor.ts index 1e6ab2fc..64aa02eb 100644 --- a/apps/api/src/modules/api-command/application/api-command-processor.ts +++ b/apps/api/src/modules/api-command/application/api-command-processor.ts @@ -1,15 +1,11 @@ -import { apiCommandsTable, appDeploymentRunsTable } from "@mosoo/db"; +import { apiCommandsTable } from "@mosoo/db"; import type { ApiCommandId } from "@mosoo/db"; -import type { AppDeploymentRunId } from "@mosoo/id"; -import { parsePlatformId } from "@mosoo/id"; -import { and, eq, inArray } from "drizzle-orm"; +import { eq } from "drizzle-orm"; import { createErrorLogContext, logError, logInfo } from "../../../platform/cloudflare/logger"; import type { ApiBindings } from "../../../platform/cloudflare/worker-types"; import { getAppDatabase } from "../../../platform/db/drizzle"; import { currentTimestampMs } from "../../../time"; -import { dispatchAppDeploymentRun } from "../../apps/application/app-deployment-executor.service"; -import { ACTIVE_APP_DEPLOYMENT_RUN_STATUSES } from "../../apps/domain/app-deployment-lifecycle"; import { parseCostLedgerReconciliationActivationMode, reconcileCostLedgerPage, @@ -18,10 +14,7 @@ import { runUsageDailyRollup } from "../../cost/application/cost-rollup.service" import { buildEnvironmentPackageArtifact } from "../../environments/application/environment-package-artifact-build.service"; import { dispatchQueuedSessionRun } from "../../runtime/application/session-runs/dispatch-queued-run.service"; import { runSandboxMaintenance } from "../../runtime/infrastructure/runtime-subject-lifecycle/runtime-subject-maintenance.service"; -import { - APP_DEPLOYMENT_RUN_DISPATCH_DEDUPE_PREFIX, - enqueueCostLedgerReconciliationCommand, -} from "./api-command-enqueue"; +import { enqueueCostLedgerReconciliationCommand } from "./api-command-enqueue"; import { API_COMMAND_LEASE_RENEWAL_INTERVAL_MS, claimApiCommand, @@ -36,17 +29,11 @@ import { parseApiCommandMessage } from "./api-command-message"; import type { ApiCommandMessage } from "./api-command-message"; import { ApiCommandPayloadError, parseApiCommandPayload } from "./api-command-payload"; import type { - AppDeploymentRunDispatchCommandPayload, CostLedgerReconciliationCommandPayload, EnvironmentPackageArtifactBuildCommandPayload, ScheduledMaintenanceCommandPayload, SessionRunDispatchCommandPayload, } from "./api-command-payload"; -import { - APP_DEPLOYMENT_RUN_DISPATCH_MAX_ATTEMPTS, - APP_DEPLOYMENT_RUN_DISPATCH_RETRY_EXHAUSTED_CODE, - createAppDeploymentDispatchRetryExhaustedMessage, -} from "./api-command-policy"; const API_COMMAND_RETRY_DELAY_SECONDS = 30; @@ -162,99 +149,6 @@ async function processSessionRunDispatchCommand( }); } -async function failActiveAppDeploymentRun( - bindings: ApiBindings, - runId: AppDeploymentRunId, - input: { errorCode: string; errorMessage: string; nowMs: number }, -): Promise { - await getAppDatabase(bindings.DB) - .update(appDeploymentRunsTable) - .set({ - errorCode: input.errorCode, - errorMessage: input.errorMessage, - status: "failed", - updatedAt: input.nowMs, - }) - .where( - and( - eq(appDeploymentRunsTable.id, runId), - inArray(appDeploymentRunsTable.status, ACTIVE_APP_DEPLOYMENT_RUN_STATUSES), - ), - ) - .run(); -} - -async function failAppDeploymentRunFromPayloadJson( - bindings: ApiBindings, - input: { - errorCode: string; - errorMessage: string; - fallbackDedupeKey?: string; - nowMs: number; - payloadJson: string; - }, - logEventName?: string, -): Promise { - try { - const runId = readAppDeploymentRunIdFromPayload(input); - - if (runId === null) { - return; - } - - await failActiveAppDeploymentRun(bindings, runId, input); - } catch (error) { - if (logEventName === undefined) { - throw error; - } - - logError(logEventName, { - ...createErrorLogContext(error), - errorCode: getErrorCode(error), - }); - } -} - -function readAppDeploymentRunIdFromPayload(input: { - fallbackDedupeKey?: string; - payloadJson: string; -}): AppDeploymentRunId | null { - try { - return ( - parseApiCommandPayload( - "app_deployment_run_dispatch", - input.payloadJson, - ) as AppDeploymentRunDispatchCommandPayload - ).appDeploymentRunId; - } catch (error) { - logError("api-command.app_deployment_run_payload_invalid", { - ...createErrorLogContext(error), - errorCode: getErrorCode(error), - }); - } - - if (input.fallbackDedupeKey === undefined) { - return null; - } - - if (!input.fallbackDedupeKey.startsWith(APP_DEPLOYMENT_RUN_DISPATCH_DEDUPE_PREFIX)) { - return null; - } - - try { - return parsePlatformId( - input.fallbackDedupeKey.slice(APP_DEPLOYMENT_RUN_DISPATCH_DEDUPE_PREFIX.length), - "app deployment run dispatch dedupe key", - ); - } catch (error) { - logError("api-command.app_deployment_run_dedupe_invalid", { - ...createErrorLogContext(error), - errorCode: getErrorCode(error), - }); - return null; - } -} - async function processClaimedApiCommand( bindings: ApiBindings, claim: ApiCommandClaim, @@ -263,10 +157,6 @@ async function processClaimedApiCommand( const payload = parseApiCommandPayload(claim.kind, claim.payloadJson); switch (claim.kind) { - case "app_deployment_run_dispatch": { - await dispatchAppDeploymentRun(bindings, payload as AppDeploymentRunDispatchCommandPayload); - return; - } case "cost_ledger_reconciliation": { await processCostLedgerReconciliationCommand( bindings, @@ -400,55 +290,6 @@ export async function processApiCommandMessage( kind: claim.kind, }); - const appDeploymentRunHasTerminalError = - claim.kind === "app_deployment_run_dispatch" && - (error instanceof ApiCommandPayloadError || - (error instanceof Error && - (error.name === "AppDeploymentDetectionError" || - error.name === "AppDeploymentNonRetryableError"))); - const appDeploymentRunRetryExhausted = - claim.kind === "app_deployment_run_dispatch" && - !appDeploymentRunHasTerminalError && - claim.attemptCount >= APP_DEPLOYMENT_RUN_DISPATCH_MAX_ATTEMPTS; - const shouldFailAppDeploymentRun = - appDeploymentRunHasTerminalError || appDeploymentRunRetryExhausted; - - if (shouldFailAppDeploymentRun) { - const failedAtMs = nowMs(); - const failureCode = appDeploymentRunRetryExhausted - ? APP_DEPLOYMENT_RUN_DISPATCH_RETRY_EXHAUSTED_CODE - : errorCode; - const failureMessage = appDeploymentRunRetryExhausted - ? createAppDeploymentDispatchRetryExhaustedMessage({ - attemptCount: claim.attemptCount, - lastErrorMessage: errorMessage, - }) - : errorMessage; - - await failAppDeploymentRunFromPayloadJson( - bindings, - { - errorCode: failureCode, - errorMessage: failureMessage, - fallbackDedupeKey: claim.dedupeKey, - nowMs: failedAtMs, - payloadJson: claim.payloadJson, - }, - "api-command.app_deployment_run_fail_failed", - ); - - await markApiCommandFailed({ - commandId, - database: bindings.DB, - errorCode: failureCode, - errorMessage: failureMessage, - nowMs: failedAtMs, - ownerId, - }); - message.ack(); - return; - } - if (error instanceof ApiCommandPayloadError) { await markApiCommandFailed({ commandId, @@ -485,31 +326,15 @@ export async function processApiCommandDeadLetterMessage( const command = (await getAppDatabase(bindings.DB) .select({ - dedupeKey: apiCommandsTable.dedupeKey, kind: apiCommandsTable.kind, lastErrorCode: apiCommandsTable.lastErrorCode, lastErrorMessage: apiCommandsTable.lastErrorMessage, - payloadJson: apiCommandsTable.payloadJson, }) .from(apiCommandsTable) .where(eq(apiCommandsTable.id, commandId)) .limit(1) .get()) ?? null; - if (command?.kind === "app_deployment_run_dispatch") { - await failAppDeploymentRunFromPayloadJson( - bindings, - { - errorCode: "queue_dead_lettered", - errorMessage: "Deployment dispatch reached the queue dead-letter consumer.", - fallbackDedupeKey: command.dedupeKey, - nowMs: deadLetteredAtMs, - payloadJson: command.payloadJson, - }, - "api-command.app_deployment_run_dead_letter_fail_failed", - ); - } - const preserveArtifactFailure = command?.kind === "environment_package_artifact_build"; await markApiCommandDeadLettered({ diff --git a/apps/api/src/modules/apps/application/app-agent-binding-resolution.ts b/apps/api/src/modules/apps/application/app-agent-binding-resolution.ts deleted file mode 100644 index 0c5ca780..00000000 --- a/apps/api/src/modules/apps/application/app-agent-binding-resolution.ts +++ /dev/null @@ -1,71 +0,0 @@ -import type { AppDeploymentAgentBinding } from "./app-deployment-detector"; - -/** - * Resolve `.mosoo.toml [[agents]]` bindings against an App's agents at deploy - * time, failing fast if any binding cannot be satisfied (PM decision #3, - * docs/prd/app-deployment.md "Agent Binding Wedge"): a deploy ships nothing - * unless every bound agent exists and is published. - * - * Pure on purpose — the caller supplies the App's agents (name + published - * flag, derived from status + liveDeploymentVersionId) so this is unit-testable - * without the database. - */ - -export type AppAgentBindingResolutionErrorCode = - | "deployment_agent_not_found" - | "deployment_agent_not_published"; - -export class AppAgentBindingResolutionError extends Error { - readonly code: AppAgentBindingResolutionErrorCode; - - constructor(code: AppAgentBindingResolutionErrorCode, message: string) { - super(message); - this.name = "AppAgentBindingResolutionError"; - this.code = code; - } -} - -export interface ResolvableAppAgent { - id: string; - name: string; - published: boolean; -} - -export interface ResolvedAppAgentBinding { - agentId: string; - envVar: string; - expose: "public_thread"; - name: string; -} - -export function resolveAppAgentBindings( - bindings: readonly AppDeploymentAgentBinding[], - agents: readonly ResolvableAppAgent[], -): ResolvedAppAgentBinding[] { - const agentsByName = new Map(agents.map((agent) => [agent.name, agent])); - - return bindings.map((binding) => { - const agent = agentsByName.get(binding.name); - - if (agent === undefined) { - throw new AppAgentBindingResolutionError( - "deployment_agent_not_found", - `Bound agent "${binding.name}" was not found in this App.`, - ); - } - - if (!agent.published) { - throw new AppAgentBindingResolutionError( - "deployment_agent_not_published", - `Bound agent "${binding.name}" is not published. Publish it, then re-run deploy.`, - ); - } - - return { - agentId: agent.id, - envVar: binding.env, - expose: binding.expose, - name: binding.name, - }; - }); -} diff --git a/apps/api/src/modules/apps/application/app-deployment-capability-authority.service.ts b/apps/api/src/modules/apps/application/app-deployment-capability-authority.service.ts deleted file mode 100644 index e29bc6d3..00000000 --- a/apps/api/src/modules/apps/application/app-deployment-capability-authority.service.ts +++ /dev/null @@ -1,171 +0,0 @@ -import { agentsTable, appDeploymentRunsTable, appDeploymentsTable } from "@mosoo/db"; -import type { AgentId, AppDeploymentId, AppDeploymentRunId, AppId } from "@mosoo/id"; -import { and, desc, eq, sql } from "drizzle-orm"; -import type { SQL } from "drizzle-orm"; - -import { getAppDatabase } from "../../../platform/db/drizzle"; - -interface DeploymentAgentCapabilityAuthority { - appId: AppId; - binding: { - env: string; - expose: "public_thread"; - name: string; - }; - deploymentId: AppDeploymentId; - deploymentRunId: AppDeploymentRunId; -} - -export type DeploymentAgentCapabilityAuthorityRejection = - | "binding_removed" - | "deployment_deleted" - | "deployment_not_activated" - | "deployment_not_found" - | "deployment_plan_invalid" - | "deployment_revision_replaced"; - -export type DeploymentAgentCapabilityAuthorityResult = - | { authorized: true } - | { authorized: false; reason: DeploymentAgentCapabilityAuthorityRejection }; - -function isRecord(value: unknown): value is Record { - return typeof value === "object" && value !== null && !Array.isArray(value); -} - -function containsBoundAgentBinding( - planJson: string, - binding: DeploymentAgentCapabilityAuthority["binding"], -): "absent" | "invalid" | "present" { - try { - const plan = JSON.parse(planJson); - - if (!isRecord(plan) || !Array.isArray(plan["agentBindings"])) { - return "invalid"; - } - - return plan["agentBindings"].some( - (candidate) => - isRecord(candidate) && - candidate["env"] === binding.env && - candidate["expose"] === binding.expose && - candidate["name"] === binding.name, - ) - ? "present" - : "absent"; - } catch { - return "invalid"; - } -} - -/** - * Verifies that a bound capability still belongs to the active Deployment and - * its latest successful binding revision. Failed deployment attempts leave the - * previous successful revision authoritative. - */ -export async function getDeploymentAgentCapabilityAuthority( - database: D1Database, - input: DeploymentAgentCapabilityAuthority, -): Promise { - const deployment = - (await getAppDatabase(database) - .select({ - deletedAt: appDeploymentsTable.deletedAt, - id: appDeploymentsTable.id, - }) - .from(appDeploymentsTable) - .where( - and( - eq(appDeploymentsTable.id, input.deploymentId), - eq(appDeploymentsTable.appId, input.appId), - ), - ) - .limit(1) - .get()) ?? null; - - if (deployment === null) { - return { authorized: false, reason: "deployment_not_found" }; - } - - if (deployment.deletedAt !== null) { - return { authorized: false, reason: "deployment_deleted" }; - } - - const currentSuccessfulRun = - (await getAppDatabase(database) - .select({ - id: appDeploymentRunsTable.id, - planJson: appDeploymentRunsTable.planJson, - }) - .from(appDeploymentRunsTable) - .where( - and( - eq(appDeploymentRunsTable.appId, input.appId), - eq(appDeploymentRunsTable.deploymentId, input.deploymentId), - eq(appDeploymentRunsTable.status, "success"), - ), - ) - .orderBy(desc(appDeploymentRunsTable.id)) - .limit(1) - .get()) ?? null; - - if (currentSuccessfulRun === null) { - return { authorized: false, reason: "deployment_not_activated" }; - } - - if (currentSuccessfulRun.planJson === null) { - return { authorized: false, reason: "deployment_plan_invalid" }; - } - - const binding = containsBoundAgentBinding(currentSuccessfulRun.planJson, input.binding); - - if (binding === "invalid") { - return { authorized: false, reason: "deployment_plan_invalid" }; - } - - if (currentSuccessfulRun.id !== input.deploymentRunId) { - return { - authorized: false, - reason: binding === "present" ? "deployment_revision_replaced" : "binding_removed", - }; - } - - return binding === "present" - ? { authorized: true } - : { authorized: false, reason: "binding_removed" }; -} - -/** - * Adds the same revocation boundary to the statement that inserts a billable - * Run. The earlier read gives useful rejection reasons; this condition closes - * the race where deletion or a successful replacement commits before the Run - * insert. The already-verified binding plan is immutable once its run is - * successful, so the current successful run ID is the revision fence here. - */ -export function createDeploymentAgentCapabilityRunCreationGuard( - input: DeploymentAgentCapabilityAuthority & { agentId: AgentId }, -): SQL { - return sql` - EXISTS ( - SELECT 1 - FROM ${appDeploymentsTable} - INNER JOIN ${agentsTable} - ON ${agentsTable.id} = ${input.agentId} - WHERE ${appDeploymentsTable.id} = ${input.deploymentId} - AND ${appDeploymentsTable.appId} = ${input.appId} - AND ${appDeploymentsTable.deletedAt} IS NULL - AND ${agentsTable.appId} = ${input.appId} - AND ${agentsTable.name} = ${input.binding.name} - AND ${agentsTable.status} = 'published' - AND ${agentsTable.liveDeploymentVersionId} IS NOT NULL - AND ${input.deploymentRunId} = ( - SELECT ${appDeploymentRunsTable.id} - FROM ${appDeploymentRunsTable} - WHERE ${appDeploymentRunsTable.appId} = ${input.appId} - AND ${appDeploymentRunsTable.deploymentId} = ${input.deploymentId} - AND ${appDeploymentRunsTable.status} = 'success' - ORDER BY ${appDeploymentRunsTable.id} DESC - LIMIT 1 - ) - ) - `; -} diff --git a/apps/api/src/modules/apps/application/app-deployment-cloudflare-client.ts b/apps/api/src/modules/apps/application/app-deployment-cloudflare-client.ts deleted file mode 100644 index 87d6670b..00000000 --- a/apps/api/src/modules/apps/application/app-deployment-cloudflare-client.ts +++ /dev/null @@ -1,406 +0,0 @@ -import Cloudflare from "cloudflare"; - -import { createErrorLogContext, logError } from "../../../platform/cloudflare/logger"; -import type { ApiBindings } from "../../../platform/cloudflare/worker-types"; - -type CloudflareWorkerVersion = Awaited< - ReturnType ->; - -export interface CloudflarePagesProjectInput { - branch: string; - projectName: string; -} - -export interface CloudflarePagesDomainInput { - hostname: string; - projectName: string; -} - -export interface CloudflareWorkerModuleInput { - compatibilityDate: string; - mainModuleName: string; - scriptContent: string; - scriptName: string; - /** Plain-text env vars injected into the Worker (e.g. agent thread URLs). */ - vars: Record; -} - -export interface CloudflareWorkerDeploymentResult { - deploymentId: string | null; - versionId: string | null; -} - -export interface CloudflarePagesDomainResult { - status: string | null; -} - -export type CloudflareDeploymentResourceTargetKind = - | "cloudflare_pages" - | "cloudflare_pages_domain" - | "cloudflare_worker" - | "cloudflare_worker_domain" - | "cloudflare_worker_route"; - -export interface CloudflareDeploymentResourceDeleteFailure { - error: unknown; - resourceName: string; - targetKind: CloudflareDeploymentResourceTargetKind; -} - -export interface CloudflareDeploymentClient { - deletePagesDomain(input: CloudflarePagesDomainInput): Promise; - deletePagesProject(input: { projectName: string }): Promise; - deleteWorkerDomain(input: { hostname: string }): Promise; - deleteWorkerRoute(input: { hostname: string }): Promise; - deleteWorkerScript(input: { scriptName: string }): Promise; - deployWorkerModule(input: CloudflareWorkerModuleInput): Promise; - ensurePagesDomain(input: CloudflarePagesDomainInput): Promise; - ensurePagesProject(input: CloudflarePagesProjectInput): Promise<{ projectId: string | null }>; - ensureWorkerDomain(input: { hostname: string; scriptName: string }): Promise; - ensureWorkerRoute(input: { hostname: string; scriptName: string }): Promise; - getLatestPagesDeployment(input: { - projectName: string; - }): Promise<{ deploymentId: string | null; url: string | null }>; -} - -export type CloudflareClientBindings = Pick< - ApiBindings, - "CLOUDFLARE_ACCOUNT_ID" | "CLOUDFLARE_API_TOKEN" | "CLOUDFLARE_ZONE_ID" ->; - -function toStatus(error: unknown, status: number): boolean { - return ( - typeof error === "object" && - error !== null && - "status" in error && - Reflect.get(error, "status") === status - ); -} - -export function logCloudflareDeploymentResourceDeleteFailures( - eventName: string, - failures: readonly CloudflareDeploymentResourceDeleteFailure[], -): void { - for (const failure of failures) { - logError(eventName, { - ...createErrorLogContext(failure.error), - resourceName: failure.resourceName, - targetKind: failure.targetKind, - }); - } -} - -export function createCloudflareDeploymentClient( - bindings: CloudflareClientBindings, -): CloudflareDeploymentClient { - const client = new Cloudflare({ apiToken: bindings.CLOUDFLARE_API_TOKEN }); - const accountId = bindings.CLOUDFLARE_ACCOUNT_ID; - const zoneId = bindings.CLOUDFLARE_ZONE_ID; - - return { - async deletePagesDomain(input) { - try { - await client.pages.projects.domains.delete(input.hostname, { - account_id: accountId, - project_name: input.projectName, - }); - } catch (error) { - if (!toStatus(error, 404)) { - throw error; - } - } - }, - async deletePagesProject(input) { - try { - await client.pages.projects.delete(input.projectName, { account_id: accountId }); - } catch (error) { - if (!toStatus(error, 404)) { - throw error; - } - } - }, - async deleteWorkerDomain(input) { - const domain = await findWorkerDomain(client, accountId, input.hostname); - - if (domain?.id === undefined) { - return; - } - - await client.workers.domains.delete(domain.id, { account_id: accountId }); - }, - async deleteWorkerRoute(input) { - const pattern = workerRoutePattern(input.hostname); - const route = await findWorkerRoute(client, zoneId, pattern); - - if (route?.id === undefined) { - return; - } - - await client.workers.routes.delete(route.id, { zone_id: zoneId }); - }, - async deleteWorkerScript(input) { - try { - await client.workers.scripts.delete(input.scriptName, { account_id: accountId }); - } catch (error) { - if (!toStatus(error, 404)) { - throw error; - } - } - }, - async deployWorkerModule(input) { - const scriptPath = `/accounts/${accountId}/workers/scripts/${encodeURIComponent(input.scriptName)}`; - const createVersion = async (): Promise => - ( - await client.post<{ result: CloudflareWorkerVersion }>(`${scriptPath}/versions`, { - body: createWorkerModuleUpload(input), - }) - ).result; - let version; - - try { - version = await createVersion(); - } catch (error) { - if (!toCloudflareCode(error, 10007)) { - throw error; - } - - await client.put(scriptPath, { - body: createWorkerModuleUpload(input), - }); - version = await createVersion(); - } - const versionId = version.id ?? null; - - if (versionId === null) { - throw new Error("Cloudflare Worker version response did not include an id."); - } - - const deployment = await client.workers.scripts.deployments.create(input.scriptName, { - account_id: accountId, - strategy: "percentage", - versions: [{ percentage: 100, version_id: versionId }], - }); - - return { - deploymentId: deployment.id ?? null, - versionId, - }; - }, - async ensurePagesDomain(input) { - try { - const domain = await client.pages.projects.domains.create(input.projectName, { - account_id: accountId, - name: input.hostname, - }); - - return { status: domain.status ?? null }; - } catch (error) { - if (!toStatus(error, 409)) { - throw error; - } - - const domain = await client.pages.projects.domains.get(input.hostname, { - account_id: accountId, - project_name: input.projectName, - }); - - return { status: domain.status ?? null }; - } - }, - async ensurePagesProject(input) { - try { - const project = await client.pages.projects.create({ - account_id: accountId, - name: input.projectName, - production_branch: input.branch, - }); - - return { projectId: project.id ?? null }; - } catch (error) { - if (!toStatus(error, 409)) { - throw error; - } - - const project = await client.pages.projects.get(input.projectName, { - account_id: accountId, - }); - - return { projectId: project.id ?? null }; - } - }, - async ensureWorkerDomain(input) { - const existingDomain = await findWorkerDomain(client, accountId, input.hostname); - - if (existingDomain !== null && existingDomain.service === input.scriptName) { - return; - } - - await client.workers.domains.update({ - account_id: accountId, - hostname: input.hostname, - service: input.scriptName, - zone_id: zoneId, - }); - }, - async ensureWorkerRoute(input) { - const pattern = workerRoutePattern(input.hostname); - const existingRoute = await findWorkerRoute(client, zoneId, pattern); - - if (existingRoute !== null) { - if (existingRoute.script !== input.scriptName && existingRoute.id !== undefined) { - await client.workers.routes.update(existingRoute.id, { - pattern, - script: input.scriptName, - zone_id: zoneId, - }); - } - - return; - } - - await client.workers.routes.create({ - pattern, - script: input.scriptName, - zone_id: zoneId, - }); - }, - async getLatestPagesDeployment(input) { - const deployments = client.pages.projects.deployments.list(input.projectName, { - account_id: accountId, - per_page: 1, - }); - - for await (const deployment of deployments) { - return { deploymentId: deployment.id ?? null, url: deployment.url ?? null }; - } - - return { deploymentId: null, url: null }; - }, - }; -} - -export async function deleteCloudflareDeploymentResources( - cloudflareClient: CloudflareDeploymentClient, - input: { hostname: string; resourceName: string }, -): Promise { - const failures = await Promise.all([ - deleteCloudflareDeploymentResource("cloudflare_pages_domain", input.resourceName, () => - cloudflareClient.deletePagesDomain({ - hostname: input.hostname, - projectName: input.resourceName, - }), - ), - deleteCloudflareDeploymentResource("cloudflare_pages", input.resourceName, () => - cloudflareClient.deletePagesProject({ projectName: input.resourceName }), - ), - deleteCloudflareDeploymentResource("cloudflare_worker_domain", input.hostname, () => - cloudflareClient.deleteWorkerDomain({ hostname: input.hostname }), - ), - deleteCloudflareDeploymentResource("cloudflare_worker_route", input.resourceName, () => - cloudflareClient.deleteWorkerRoute({ hostname: input.hostname }), - ), - deleteCloudflareDeploymentResource("cloudflare_worker", input.resourceName, () => - cloudflareClient.deleteWorkerScript({ scriptName: input.resourceName }), - ), - ]); - - return failures.filter( - (failure): failure is CloudflareDeploymentResourceDeleteFailure => failure !== null, - ); -} - -async function deleteCloudflareDeploymentResource( - targetKind: CloudflareDeploymentResourceTargetKind, - resourceName: string, - runDelete: () => Promise, -): Promise { - try { - await runDelete(); - return null; - } catch (error) { - return { error, resourceName, targetKind }; - } -} - -function workerRoutePattern(hostname: string): string { - return `${hostname}/*`; -} - -export function createWorkerModuleUpload(input: CloudflareWorkerModuleInput): FormData { - const upload = new FormData(); - const file = new File([input.scriptContent], input.mainModuleName, { - type: "application/javascript+module", - }); - const metadata = { - bindings: Object.entries(input.vars).map(([name, text]) => ({ - name, - text, - type: "plain_text" as const, - })), - compatibility_date: input.compatibilityDate, - main_module: input.mainModuleName, - }; - - upload.append("metadata", JSON.stringify(metadata)); - upload.append(input.mainModuleName, file); - - return upload; -} - -function toCloudflareCode(error: unknown, code: number): boolean { - if (typeof error !== "object" || error === null) { - return false; - } - - if ("code" in error && Reflect.get(error, "code") === code) { - return true; - } - - const cause = Reflect.get(error, "error"); - - if (typeof cause === "object" && cause !== null && Reflect.get(cause, "code") === code) { - return true; - } - - const errors = Reflect.get(error, "errors"); - - return ( - Array.isArray(errors) && - errors.some( - (entry) => typeof entry === "object" && entry !== null && Reflect.get(entry, "code") === code, - ) - ); -} - -async function findWorkerDomain( - client: Cloudflare, - accountId: string, - hostname: string, -): Promise<{ id?: string; service?: string } | null> { - const domains = client.workers.domains.list({ account_id: accountId }); - - for await (const domain of domains) { - if (domain.hostname === hostname) { - return domain; - } - } - - return null; -} - -async function findWorkerRoute( - client: Cloudflare, - zoneId: string, - pattern: string, -): Promise<{ id?: string; script?: string } | null> { - const routes = client.workers.routes.list({ zone_id: zoneId }); - - for await (const route of routes) { - if (route.pattern === pattern) { - return route; - } - } - - return null; -} diff --git a/apps/api/src/modules/apps/application/app-deployment-detector.ts b/apps/api/src/modules/apps/application/app-deployment-detector.ts deleted file mode 100644 index 80de1a29..00000000 --- a/apps/api/src/modules/apps/application/app-deployment-detector.ts +++ /dev/null @@ -1,936 +0,0 @@ -import type { AppDeploymentTargetKind } from "@mosoo/db"; -import type { ParseError } from "jsonc-parser"; -import { parse as parseJsonc } from "jsonc-parser"; -import { parse as parseToml, stringify } from "smol-toml"; - -export type AppDeploymentPackageManager = "bun" | "none" | "npm" | "pnpm" | "yarn"; -export type AppDeploymentTargetMode = "static_assets" | "worker_module" | "worker_with_assets"; -export type AppDeploymentDetectionErrorCode = - | "deployment_config_required" - | "deployment_shape_unsupported"; - -export interface AppDeploymentAgentBinding { - env: string; - expose: "public_thread"; - name: string; -} - -export interface AppDeploymentPlan { - agentBindings: AppDeploymentAgentBinding[]; - buildCommand: string | null; - generatedWranglerConfig: string; - installCommand: string | null; - mosooConfigPath: ".mosoo.toml" | null; - outputDir: string | null; - packageManager: AppDeploymentPackageManager; - routesFallback: string | null; - rootDir: string; - targetKind: AppDeploymentTargetKind; - targetMode: AppDeploymentTargetMode; - warnings: string[]; - workerEntry: string | null; -} - -export interface AppDeploymentRepositorySnapshot { - files: Readonly>; -} - -export interface AppDeploymentDetectionOptions { - resourceName: string; -} - -interface PackageJson { - dependencies: Readonly>; - devDependencies: Readonly>; - optionalDependencies: Readonly>; - packageManager: string | null; - peerDependencies: Readonly>; - scripts: Readonly>; -} - -interface MosooConfig { - agents: AppDeploymentAgentBinding[]; - buildCommand: string | null; - installCommand: string | null; - outputDir: string | null; - routesFallback: string | null; - rootDir: string; - workerEntry: string | null; - wranglerConfigPath: string | null; - type: "static" | "worker"; -} - -interface RepositoryFiles { - has(path: string): boolean; - read(path: string): string | null; -} - -export const APP_DEPLOYMENT_COMPATIBILITY_DATE = "2026-06-26"; -const WORKER_JS_ENTRY_PATTERN = /\.(?:mjs|js)$/u; - -export class AppDeploymentDetectionError extends Error { - readonly code: AppDeploymentDetectionErrorCode; - - constructor(code: AppDeploymentDetectionErrorCode, message: string) { - super(message); - this.name = "AppDeploymentDetectionError"; - this.code = code; - } -} - -export function detectAppDeploymentPlan( - snapshot: AppDeploymentRepositorySnapshot, - options: AppDeploymentDetectionOptions, -): AppDeploymentPlan { - const files = createRepositoryFiles(snapshot.files); - const mosooConfig = files.read(".mosoo.toml"); - const resourceName = normalizeResourceName(options.resourceName); - - if (mosooConfig !== null) { - return detectFromMosooConfig(files, mosooConfig, resourceName); - } - - return detectFromRepository(files, ".", resourceName); -} - -function detectFromMosooConfig( - files: RepositoryFiles, - source: string, - resourceName: string, -): AppDeploymentPlan { - const config = parseMosooConfig(source); - const packageJson = readPackageJson(files, config.rootDir); - const packageManager = detectPackageManager(files, config.rootDir, packageJson); - const installCommand = - config.installCommand ?? installCommandFor(packageManager, files, config.rootDir); - const buildCommand = config.buildCommand ?? buildCommandFor(packageManager, packageJson); - - if (config.type === "static") { - if (config.agents.length > 0) { - throw new AppDeploymentDetectionError( - "deployment_shape_unsupported", - "agent bindings ([[agents]]) require a worker deployment", - ); - } - - const outputDir = - config.outputDir ?? - fail("deployment_config_required", "static deployment requires build.output"); - - return pagesPlan({ - agentBindings: config.agents, - buildCommand, - installCommand, - mosooConfigPath: ".mosoo.toml", - outputDir, - packageManager, - resourceName, - routesFallback: config.routesFallback, - rootDir: config.rootDir, - }); - } - - const workerEntry = - config.workerEntry ?? - readWranglerMain(files, config.rootDir, config.wranglerConfigPath) ?? - fail("deployment_config_required", "worker deployment requires worker.entry"); - - if (config.routesFallback !== null) { - throw new AppDeploymentDetectionError( - "deployment_config_required", - "routes.fallback is only supported for static deployment", - ); - } - - return workerPlan({ - agentBindings: config.agents, - buildCommand, - installCommand, - mosooConfigPath: ".mosoo.toml", - packageManager, - resourceName, - rootDir: config.rootDir, - workerEntry, - }); -} - -function detectFromRepository( - files: RepositoryFiles, - rootDir: string, - resourceName: string, -): AppDeploymentPlan { - const packageJson = readPackageJson(files, rootDir); - const packageManager = detectPackageManager(files, rootDir, packageJson); - const wranglerMain = readWranglerMain(files, rootDir); - - if (wranglerMain !== null) { - return workerPlan({ - agentBindings: [], - buildCommand: buildCommandFor(packageManager, packageJson), - installCommand: installCommandFor(packageManager, files, rootDir), - mosooConfigPath: null, - packageManager, - resourceName, - rootDir, - workerEntry: wranglerMain, - }); - } - - if (packageJson === null) { - if (files.has("index.html")) { - return pagesPlan({ - agentBindings: [], - buildCommand: null, - installCommand: null, - mosooConfigPath: null, - outputDir: ".", - packageManager: "none", - resourceName, - routesFallback: null, - rootDir, - }); - } - - throw new AppDeploymentDetectionError( - "deployment_config_required", - "repository does not match a supported deployment shape", - ); - } - - if (hasDependency(packageJson, "vite")) { - return packagePagesPlan(files, rootDir, packageJson, packageManager, "dist", resourceName); - } - - if (hasDependency(packageJson, "astro")) { - return packagePagesPlan(files, rootDir, packageJson, packageManager, "dist", resourceName); - } - - if (hasDependency(packageJson, "@docusaurus/core")) { - return packagePagesPlan(files, rootDir, packageJson, packageManager, "build", resourceName); - } - - if (hasDependency(packageJson, "next")) { - if (isNextStaticExport(files, rootDir, packageJson)) { - return packagePagesPlan(files, rootDir, packageJson, packageManager, "out", resourceName); - } - - throw new AppDeploymentDetectionError( - "deployment_config_required", - "Next.js deployment requires explicit static export", - ); - } - - if (files.has(pathInRoot(rootDir, "index.html")) && packageJson.scripts["build"] === undefined) { - return pagesPlan({ - agentBindings: [], - buildCommand: null, - installCommand: null, - mosooConfigPath: null, - outputDir: ".", - packageManager: "none", - resourceName, - routesFallback: null, - rootDir, - }); - } - - throw new AppDeploymentDetectionError( - "deployment_config_required", - "repository does not match a supported deployment shape", - ); -} - -function packagePagesPlan( - files: RepositoryFiles, - rootDir: string, - packageJson: PackageJson, - packageManager: AppDeploymentPackageManager, - outputDir: string, - resourceName: string, -): AppDeploymentPlan { - const buildCommand = - buildCommandFor(packageManager, packageJson) ?? - fail("deployment_config_required", "static framework deployment requires scripts.build"); - - return pagesPlan({ - agentBindings: [], - buildCommand, - installCommand: installCommandFor(packageManager, files, rootDir), - mosooConfigPath: null, - outputDir, - packageManager, - resourceName, - routesFallback: null, - rootDir, - }); -} - -function pagesPlan(input: { - agentBindings: AppDeploymentAgentBinding[]; - buildCommand: string | null; - installCommand: string | null; - mosooConfigPath: ".mosoo.toml" | null; - outputDir: string; - packageManager: AppDeploymentPackageManager; - resourceName: string; - routesFallback: string | null; - rootDir: string; -}): AppDeploymentPlan { - return { - agentBindings: input.agentBindings, - buildCommand: input.buildCommand, - generatedWranglerConfig: stringify({ - compatibility_date: APP_DEPLOYMENT_COMPATIBILITY_DATE, - name: input.resourceName, - pages_build_output_dir: input.outputDir, - }), - installCommand: input.installCommand, - mosooConfigPath: input.mosooConfigPath, - outputDir: input.outputDir, - packageManager: input.packageManager, - routesFallback: input.routesFallback, - rootDir: input.rootDir, - targetKind: "cloudflare_pages", - targetMode: "static_assets", - warnings: [], - workerEntry: null, - }; -} - -function workerPlan(input: { - agentBindings: AppDeploymentAgentBinding[]; - buildCommand: string | null; - installCommand: string | null; - mosooConfigPath: ".mosoo.toml" | null; - packageManager: AppDeploymentPackageManager; - resourceName: string; - rootDir: string; - workerEntry: string; -}): AppDeploymentPlan { - if (!WORKER_JS_ENTRY_PATTERN.test(input.workerEntry)) { - throw new AppDeploymentDetectionError( - "deployment_config_required", - "worker.entry must point to a JavaScript module file", - ); - } - - return { - agentBindings: input.agentBindings, - buildCommand: input.buildCommand, - generatedWranglerConfig: stringify({ - compatibility_date: APP_DEPLOYMENT_COMPATIBILITY_DATE, - main: input.workerEntry, - name: input.resourceName, - }), - installCommand: input.installCommand, - mosooConfigPath: input.mosooConfigPath, - outputDir: null, - packageManager: input.packageManager, - routesFallback: null, - rootDir: input.rootDir, - targetKind: "cloudflare_worker", - targetMode: "worker_module", - warnings: [], - workerEntry: input.workerEntry, - }; -} - -function createRepositoryFiles(files: Readonly>): RepositoryFiles { - const normalized = new Map(); - - for (const [path, content] of Object.entries(files)) { - normalized.set(normalizePath(path), content); - } - - return { - has(path) { - return normalized.has(normalizePath(path)); - }, - read(path) { - return normalized.get(normalizePath(path)) ?? null; - }, - }; -} - -function parseMosooConfig(source: string): MosooConfig { - const value = parseTomlObject(source, ".mosoo.toml"); - requireAllowedKeys( - value, - ["agents", "build", "deploy", "name", "root", "routes", "schema", "type", "worker"], - ".mosoo.toml", - ); - - readSchemaVersion(value); - - const deploy = readTable(value, "deploy", ".mosoo.toml"); - requireAllowedKeys(deploy, ["adapter", "wrangler"], ".mosoo.toml deploy"); - const deployAdapter = value["deploy"] === undefined ? null : readDeployAdapter(deploy); - const wranglerConfigPath = normalizeOptionalRelativePath( - readOptionalString(deploy, "wrangler", ".mosoo.toml deploy"), - "deploy.wrangler", - ); - - const type = resolveDeploymentType( - readOptionalString(value, "type", ".mosoo.toml"), - deployAdapter, - ); - - const build = readTable(value, "build", ".mosoo.toml"); - const worker = readTable(value, "worker", ".mosoo.toml"); - const routes = readTable(value, "routes", ".mosoo.toml"); - const routesFallback = normalizeOptionalRelativePath( - readOptionalString(routes, "fallback", ".mosoo.toml routes"), - "routes.fallback", - ); - - requireAllowedKeys(build, ["command", "install", "output"], ".mosoo.toml build"); - requireAllowedKeys(worker, ["entry"], ".mosoo.toml worker"); - requireAllowedKeys(routes, ["fallback"], ".mosoo.toml routes"); - readOptionalString(value, "name", ".mosoo.toml"); - - return { - agents: readAgentBindings(value), - buildCommand: readOptionalString(build, "command", ".mosoo.toml build"), - installCommand: readOptionalString(build, "install", ".mosoo.toml build"), - outputDir: normalizeOptionalRelativePath( - readOptionalString(build, "output", ".mosoo.toml build"), - "build.output", - ), - rootDir: normalizeRelativePath(readOptionalString(value, "root", ".mosoo.toml") ?? ".", "root"), - routesFallback, - type, - workerEntry: normalizeOptionalRelativePath( - readOptionalString(worker, "entry", ".mosoo.toml worker"), - "worker.entry", - ), - wranglerConfigPath, - }; -} - -function readSchemaVersion(value: Readonly>): void { - const schema = value["schema"]; - - if (schema === undefined) { - return; - } - - if (typeof schema !== "number" || !Number.isInteger(schema)) { - throw new AppDeploymentDetectionError( - "deployment_config_required", - ".mosoo.toml schema must be an integer", - ); - } - - if (schema !== 1) { - throw new AppDeploymentDetectionError( - "deployment_shape_unsupported", - ".mosoo.toml schema must be 1", - ); - } -} - -function readDeployAdapter(deploy: Readonly>): "cloudflare-workers" { - const adapter = readRequiredString(deploy, "adapter", ".mosoo.toml deploy"); - - if (adapter !== "cloudflare-workers") { - throw new AppDeploymentDetectionError( - "deployment_shape_unsupported", - ".mosoo.toml deploy.adapter must be cloudflare-workers", - ); - } - - return "cloudflare-workers"; -} - -function resolveDeploymentType( - flatType: string | null, - deployAdapter: "cloudflare-workers" | null, -): "static" | "worker" { - if (flatType !== null) { - if (flatType !== "static" && flatType !== "worker") { - throw new AppDeploymentDetectionError( - "deployment_shape_unsupported", - ".mosoo.toml type must be static or worker", - ); - } - - return flatType; - } - - if (deployAdapter === "cloudflare-workers") { - return "worker"; - } - - throw new AppDeploymentDetectionError( - "deployment_config_required", - ".mosoo.toml must declare type or [deploy].adapter", - ); -} - -function readPackageJson(files: RepositoryFiles, rootDir: string): PackageJson | null { - const path = pathInRoot(rootDir, "package.json"); - const content = files.read(path); - - if (content === null) { - return null; - } - - const value = parseJsonObject(content, path); - - return { - dependencies: readStringRecord(value, "dependencies", path), - devDependencies: readStringRecord(value, "devDependencies", path), - optionalDependencies: readStringRecord(value, "optionalDependencies", path), - packageManager: readOptionalString(value, "packageManager", path), - peerDependencies: readStringRecord(value, "peerDependencies", path), - scripts: readStringRecord(value, "scripts", path), - }; -} - -function readWranglerMain( - files: RepositoryFiles, - rootDir: string, - configPath: string | null = null, -): string | null { - const candidates = - configPath === null ? ["wrangler.toml", "wrangler.json", "wrangler.jsonc"] : [configPath]; - - for (const file of candidates) { - const main = readWranglerMainFromFile(files, pathInRoot(rootDir, file)); - - if (main !== null) return main; - } - - return null; -} - -function readWranglerMainFromFile(files: RepositoryFiles, path: string): string | null { - const content = files.read(path); - - if (content === null) { - return null; - } - - return readWranglerConfigMain(() => { - const value = path.endsWith(".toml") - ? parseTomlObject(content, path) - : parseJsonObject(content, path); - - return normalizeOptionalRelativePath(readOptionalString(value, "main", path), "main"); - }); -} - -function readWranglerConfigMain(readMain: () => string | null): string | null { - try { - return readMain(); - } catch { - return null; - } -} - -function detectPackageManager( - files: RepositoryFiles, - rootDir: string, - packageJson: PackageJson | null, -): AppDeploymentPackageManager { - if (files.has(pathInRoot(rootDir, "bun.lock")) || files.has(pathInRoot(rootDir, "bun.lockb"))) { - return "bun"; - } - - if (files.has(pathInRoot(rootDir, "pnpm-lock.yaml"))) { - return "pnpm"; - } - - if (files.has(pathInRoot(rootDir, "yarn.lock"))) { - return "yarn"; - } - - if ( - files.has(pathInRoot(rootDir, "package-lock.json")) || - files.has(pathInRoot(rootDir, "npm-shrinkwrap.json")) - ) { - return "npm"; - } - - if (packageJson?.packageManager?.startsWith("bun@")) { - return "bun"; - } - - if (packageJson?.packageManager?.startsWith("pnpm@")) { - return "pnpm"; - } - - if (packageJson?.packageManager?.startsWith("yarn@")) { - return "yarn"; - } - - if (packageJson !== null) { - return "npm"; - } - - return "none"; -} - -function installCommandFor( - packageManager: AppDeploymentPackageManager, - files: RepositoryFiles, - rootDir: string, -): string | null { - switch (packageManager) { - case "bun": - return files.has(pathInRoot(rootDir, "bun.lock")) || - files.has(pathInRoot(rootDir, "bun.lockb")) - ? "bun install --frozen-lockfile" - : "bun install"; - case "npm": - return files.has(pathInRoot(rootDir, "package-lock.json")) ? "npm ci" : "npm install"; - case "pnpm": - return files.has(pathInRoot(rootDir, "pnpm-lock.yaml")) - ? "pnpm install --frozen-lockfile" - : "pnpm install"; - case "yarn": - return files.has(pathInRoot(rootDir, "yarn.lock")) - ? "yarn install --frozen-lockfile" - : "yarn install"; - case "none": - return null; - } -} - -function buildCommandFor( - packageManager: AppDeploymentPackageManager, - packageJson: PackageJson | null, -): string | null { - if (packageJson?.scripts["build"] === undefined || packageManager === "none") { - return null; - } - - switch (packageManager) { - case "bun": - return "bun run build"; - case "npm": - return "npm run build"; - case "pnpm": - return "pnpm run build"; - case "yarn": - return "yarn build"; - } -} - -function isNextStaticExport( - files: RepositoryFiles, - rootDir: string, - packageJson: PackageJson, -): boolean { - const buildScript = packageJson.scripts["build"] ?? ""; - - if (buildScript.includes("next export")) { - return true; - } - - for (const file of ["next.config.js", "next.config.mjs", "next.config.ts"]) { - const content = files.read(pathInRoot(rootDir, file)); - - if (content !== null && /\boutput\s*:\s*["'`]export["'`]/u.test(content)) { - return true; - } - } - - return false; -} - -function hasDependency(packageJson: PackageJson, name: string): boolean { - return ( - packageJson.dependencies[name] !== undefined || - packageJson.devDependencies[name] !== undefined || - packageJson.optionalDependencies[name] !== undefined || - packageJson.peerDependencies[name] !== undefined - ); -} - -function parseJsonObject(content: string, path: string): Record { - const errors: ParseError[] = []; - let value: unknown; - - try { - if (path.endsWith(".jsonc")) { - value = parseJsonc(content, errors, { allowTrailingComma: true }); - } else { - value = JSON.parse(content); - } - } catch { - throw new AppDeploymentDetectionError( - "deployment_config_required", - `${path} must be valid JSON`, - ); - } - - if (errors.length > 0) { - throw new AppDeploymentDetectionError( - "deployment_config_required", - `${path} must be valid JSONC`, - ); - } - - if (!isRecord(value)) { - throw new AppDeploymentDetectionError( - "deployment_config_required", - `${path} must be an object`, - ); - } - - return value; -} - -function parseTomlObject(content: string, path: string): Record { - let value: unknown; - - try { - value = parseToml(content); - } catch { - throw new AppDeploymentDetectionError( - "deployment_config_required", - `${path} must be valid TOML`, - ); - } - - if (!isRecord(value)) { - throw new AppDeploymentDetectionError("deployment_config_required", `${path} must be a table`); - } - - return value; -} - -function readStringRecord( - source: Readonly>, - key: string, - path: string, -): Readonly> { - const value = source[key]; - - if (value === undefined) { - return {}; - } - - if (!isRecord(value)) { - throw new AppDeploymentDetectionError( - "deployment_config_required", - `${path}.${key} must be an object`, - ); - } - - const result: Record = {}; - - for (const [recordKey, recordValue] of Object.entries(value)) { - if (typeof recordValue !== "string") { - throw new AppDeploymentDetectionError( - "deployment_config_required", - `${path}.${key}.${recordKey} must be a string`, - ); - } - - result[recordKey] = recordValue; - } - - return result; -} - -function readTable( - source: Readonly>, - key: string, - path: string, -): Readonly> { - const value = source[key]; - - if (value === undefined) { - return {}; - } - - if (!isRecord(value)) { - throw new AppDeploymentDetectionError( - "deployment_config_required", - `${path}.${key} must be a table`, - ); - } - - return value; -} - -function readTableArray( - source: Readonly>, - key: string, - path: string, -): readonly Record[] { - const value = source[key]; - - if (value === undefined) { - return []; - } - - if (!Array.isArray(value)) { - throw new AppDeploymentDetectionError( - "deployment_config_required", - `${path}.${key} must be an array of tables`, - ); - } - - return value.map((entry, index) => { - if (!isRecord(entry)) { - throw new AppDeploymentDetectionError( - "deployment_config_required", - `${path}.${key}[${index}] must be a table`, - ); - } - - return entry; - }); -} - -function readAgentBindings(value: Readonly>): AppDeploymentAgentBinding[] { - const bindings = readTableArray(value, "agents", ".mosoo.toml").map( - (entry, index): AppDeploymentAgentBinding => { - const path = `.mosoo.toml agents[${index}]`; - requireAllowedKeys(entry, ["env", "expose", "name"], path); - - if (readRequiredString(entry, "expose", path) !== "public_thread") { - throw new AppDeploymentDetectionError( - "deployment_shape_unsupported", - `${path}.expose must be public_thread`, - ); - } - - return { - env: readRequiredString(entry, "env", path), - expose: "public_thread", - name: readRequiredString(entry, "name", path), - }; - }, - ); - - const seenNames = new Set(); - const seenEnvs = new Set(); - - for (const binding of bindings) { - if (seenNames.has(binding.name)) { - throw new AppDeploymentDetectionError( - "deployment_config_required", - `.mosoo.toml agents.name "${binding.name}" is duplicated`, - ); - } - - seenNames.add(binding.name); - - if (seenEnvs.has(binding.env)) { - throw new AppDeploymentDetectionError( - "deployment_config_required", - `.mosoo.toml agents.env "${binding.env}" is duplicated`, - ); - } - - seenEnvs.add(binding.env); - } - - return bindings; -} - -function readRequiredString( - source: Readonly>, - key: string, - path: string, -): string { - return ( - readOptionalString(source, key, path) ?? - fail("deployment_config_required", `${path}.${key} is required`) - ); -} - -function readOptionalString( - source: Readonly>, - key: string, - path: string, -): string | null { - const value = source[key]; - - if (value === undefined) { - return null; - } - - if (typeof value !== "string" || value.trim() === "") { - throw new AppDeploymentDetectionError( - "deployment_config_required", - `${path}.${key} must be a non-empty string`, - ); - } - - return value; -} - -function requireAllowedKeys( - source: Readonly>, - allowedKeys: readonly string[], - path: string, -): void { - for (const key of Object.keys(source)) { - if (!allowedKeys.includes(key)) { - throw new AppDeploymentDetectionError( - "deployment_config_required", - `${path}.${key} is not supported`, - ); - } - } -} - -function normalizeOptionalRelativePath(path: string | null, field: string): string | null { - if (path === null) { - return null; - } - - return normalizeRelativePath(path, field); -} - -function normalizeResourceName(value: string): string { - const name = value.trim(); - - if (name.length === 0) { - throw new AppDeploymentDetectionError( - "deployment_config_required", - "deployment resource name is required", - ); - } - - return name; -} - -function normalizeRelativePath(path: string, field: string): string { - const rawPath = path.replaceAll("\\", "/"); - const parts = rawPath.split("/").filter((part) => part !== "" && part !== "."); - - if (rawPath.startsWith("/") || rawPath.includes("\0") || parts.includes("..")) { - throw new AppDeploymentDetectionError( - "deployment_config_required", - `${field} must stay inside the repository`, - ); - } - - return parts.length === 0 ? "." : parts.join("/"); -} - -function normalizePath(path: string): string { - return path - .replaceAll("\\", "/") - .split("/") - .filter((part) => part !== "" && part !== ".") - .join("/"); -} - -function pathInRoot(rootDir: string, path: string): string { - return rootDir === "." ? path : `${rootDir}/${path}`; -} - -function isRecord(value: unknown): value is Record { - return typeof value === "object" && value !== null && !Array.isArray(value); -} - -function fail(code: AppDeploymentDetectionErrorCode, message: string): never { - throw new AppDeploymentDetectionError(code, message); -} diff --git a/apps/api/src/modules/apps/application/app-deployment-executor.service.ts b/apps/api/src/modules/apps/application/app-deployment-executor.service.ts deleted file mode 100644 index 87ca0bb5..00000000 --- a/apps/api/src/modules/apps/application/app-deployment-executor.service.ts +++ /dev/null @@ -1,1015 +0,0 @@ -import type { AppDeploymentRunStatus } from "@mosoo/contracts/app"; -import type { AppDeploymentRunRow, AppDeploymentRow } from "@mosoo/db"; -import { appDeploymentRunsTable, appDeploymentsTable } from "@mosoo/db"; -import { parsePlatformId } from "@mosoo/id"; -import type { AgentId, AppDeploymentRunId } from "@mosoo/id"; -import { and, eq, inArray, isNotNull, isNull } from "drizzle-orm"; - -import { createErrorLogContext, logError } from "../../../platform/cloudflare/logger"; -import type { ApiBindings } from "../../../platform/cloudflare/worker-types"; -import { getAppDatabase, getD1ChangeCount } from "../../../platform/db/drizzle"; -import { currentTimestampMs } from "../../../time"; -import { listAppOwnerAgentRows } from "../../agents/application/agent-repository"; -import { boundAgentUrl, mintAppAgentCapabilityToken } from "../../public-api/app-agent-capability"; -import { - destroyRuntimeSubjectContainer, - getRuntimeSubjectKeepAliveHandle, -} from "../../runtime/infrastructure/runtime-subject-lifecycle/runtime-subject-platform"; -import type { - ExecutionSessionHandle, - SandboxHandle, -} from "../../runtime/infrastructure/sandbox-handles"; -import { ACTIVE_APP_DEPLOYMENT_RUN_STATUSES } from "../domain/app-deployment-lifecycle"; -import { - AppAgentBindingResolutionError, - resolveAppAgentBindings, -} from "./app-agent-binding-resolution"; -import type { ResolvableAppAgent } from "./app-agent-binding-resolution"; -import type { CloudflareDeploymentClient } from "./app-deployment-cloudflare-client"; -import { - createCloudflareDeploymentClient, - deleteCloudflareDeploymentResources, - logCloudflareDeploymentResourceDeleteFailures, -} from "./app-deployment-cloudflare-client"; -import { - APP_DEPLOYMENT_COMPATIBILITY_DATE, - detectAppDeploymentPlan, -} from "./app-deployment-detector"; -import type { AppDeploymentPlan, AppDeploymentRepositorySnapshot } from "./app-deployment-detector"; - -interface AppDeploymentDispatchContext { - deployment: AppDeploymentRow; - run: AppDeploymentRunRow; -} - -interface PreparedAppDeploymentRepository { - repoDir: string; - snapshot: AppDeploymentRepositorySnapshot; -} - -interface AppDeploymentDeployResult { - externalDeploymentId: string | null; - externalProjectId: string | null; - externalVersionId: string | null; - url: string; -} - -export interface AppDeploymentBuildRunner { - build(input: { - plan: AppDeploymentPlan; - prepared: PreparedAppDeploymentRepository; - }): Promise; - cleanup?(): Promise; - deploy(input: { - deployment: AppDeploymentRow; - envVars: Record; - plan: AppDeploymentPlan; - prepared: PreparedAppDeploymentRepository; - run: AppDeploymentRunRow; - }): Promise; - prepare(input: { - deployment: AppDeploymentRow; - run: AppDeploymentRunRow; - }): Promise; -} - -export interface DispatchAppDeploymentRunOptions { - cloudflareClient?: CloudflareDeploymentClient; - runner?: AppDeploymentBuildRunner; -} - -export class AppDeploymentNonRetryableError extends Error { - constructor(message: string) { - super(message); - this.name = "AppDeploymentNonRetryableError"; - } -} - -const SNAPSHOT_FILE_NAMES = new Set([ - ".mosoo.toml", - "bun.lock", - "bun.lockb", - "index.html", - "next.config.js", - "next.config.mjs", - "next.config.ts", - "npm-shrinkwrap.json", - "package-lock.json", - "package.json", - "pnpm-lock.yaml", - "wrangler.json", - "wrangler.jsonc", - "wrangler.toml", - "yarn.lock", -]); -const WORKER_JS_ENTRY_PATTERN = /\.(?:mjs|js)$/u; -export function appDeploymentBuildSandboxId(runId: AppDeploymentRunId): string { - return `${runId}-build`; -} - -export function appDeploymentDeploySandboxId(runId: AppDeploymentRunId): string { - return `${runId}-deploy`; -} - -function quoteShellArg(value: string): string { - return `'${value.replaceAll("'", `'"'"'`)}'`; -} - -function deploymentHostname(deployment: AppDeploymentRow, domain: string): string { - return `${deployment.mosooSubdomain}.${domain}`; -} - -function deploymentUrl(deployment: AppDeploymentRow, domain: string): string { - return `https://${deploymentHostname(deployment, domain)}`; -} - -function isActiveRunStatus(status: AppDeploymentRunStatus): boolean { - return (ACTIVE_APP_DEPLOYMENT_RUN_STATUSES as readonly AppDeploymentRunStatus[]).includes(status); -} - -function commandFailureMessage( - result: { exitCode: number; stderr: string; stdout: string; success: boolean }, - label: string, -): string | null { - if (result.success && result.exitCode === 0) { - return null; - } - - return result.stderr.trim() || result.stdout.trim() || `${label} failed.`; -} - -function assertSuccessfulCommand( - result: { exitCode: number; stderr: string; stdout: string; success: boolean }, - label: string, -): void { - const message = commandFailureMessage(result, label); - - if (message !== null) { - throw new Error(message); - } -} - -async function execChecked( - session: ExecutionSessionHandle, - command: string, - label: string, - options: { retryable?: boolean } = {}, -): Promise { - const message = commandFailureMessage( - await session.exec(`sh -lc ${quoteShellArg(command)}`), - label, - ); - - if (message === null) { - return; - } - - if (options.retryable === false) { - throw new AppDeploymentNonRetryableError(message); - } - - throw new Error(message); -} - -function assertSelfContainedWorkerModule(scriptContent: string): void { - if (/^\s*import\s/mu.test(scriptContent) || /\bimport\s*\(/u.test(scriptContent)) { - throw new AppDeploymentNonRetryableError( - "Worker deployment only supports self-contained JavaScript modules in the first cut.", - ); - } -} - -function assertRequestedMosooConfigPresent( - run: AppDeploymentRunRow, - snapshot: AppDeploymentRepositorySnapshot, -): void { - if (run.mosooConfigJson === null) { - return; - } - - let parsed: unknown; - - try { - parsed = JSON.parse(run.mosooConfigJson); - } catch { - throw new AppDeploymentNonRetryableError("App deployment config metadata is invalid."); - } - - if ( - typeof parsed !== "object" || - parsed === null || - Reflect.get(parsed, "configPath") !== ".mosoo.toml" - ) { - throw new AppDeploymentNonRetryableError("App deployment config metadata is invalid."); - } - - if (snapshot.files[".mosoo.toml"] === undefined) { - throw new AppDeploymentNonRetryableError("Requested .mosoo.toml was not found."); - } -} - -async function assertControlledWranglerAvailable(sandbox: SandboxHandle): Promise { - await execChecked( - sandbox, - "command -v wrangler >/dev/null && wrangler --version >/dev/null", - "Controlled Wrangler availability", - { retryable: false }, - ); -} - -export async function destroyAppDeploymentRunSandboxesBestEffort( - bindings: ApiBindings, - runId: AppDeploymentRunId, -): Promise { - await Promise.all([ - destroyDeploymentSandboxBestEffort( - bindings, - appDeploymentBuildSandboxId(runId), - "app-deployment.build_sandbox_destroy_failed", - ), - destroyDeploymentSandboxBestEffort( - bindings, - appDeploymentDeploySandboxId(runId), - "app-deployment.deploy_sandbox_destroy_failed", - ), - ]); -} - -async function destroyDeploymentSandboxBestEffort( - bindings: ApiBindings, - sandboxId: string, - eventName: string, -): Promise { - try { - await destroyRuntimeSubjectContainer(bindings, sandboxId); - } catch (error) { - logError(eventName, { - ...createErrorLogContext(error), - sandboxId, - }); - } -} - -async function readCurrentDispatchContext( - database: D1Database, - runId: AppDeploymentRunId, -): Promise { - const run = - (await getAppDatabase(database) - .select() - .from(appDeploymentRunsTable) - .where(eq(appDeploymentRunsTable.id, runId)) - .limit(1) - .get()) ?? null; - - if (run === null || !isActiveRunStatus(run.status)) { - return null; - } - - const deployment = - (await getAppDatabase(database) - .select() - .from(appDeploymentsTable) - .where( - and( - eq(appDeploymentsTable.id, run.deploymentId), - eq(appDeploymentsTable.latestRunId, run.id), - isNull(appDeploymentsTable.deletedAt), - ), - ) - .limit(1) - .get()) ?? null; - - return deployment === null ? null : { deployment, run }; -} - -async function updateRunStatus( - database: D1Database, - runId: AppDeploymentRunId, - status: Extract< - AppDeploymentRunStatus, - "activating" | "building" | "preparing" | "submitted" | "submitting" - >, -): Promise { - const result = await getAppDatabase(database) - .update(appDeploymentRunsTable) - .set({ status, updatedAt: currentTimestampMs() }) - .where( - and( - eq(appDeploymentRunsTable.id, runId), - inArray(appDeploymentRunsTable.status, ACTIVE_APP_DEPLOYMENT_RUN_STATUSES), - ), - ) - .run(); - - return getD1ChangeCount(result) > 0; -} - -async function storeDeploymentPlan(input: { - database: D1Database; - plan: AppDeploymentPlan; - runId: AppDeploymentRunId; - targetName: string; -}): Promise { - const targetKind = input.plan.targetKind; - const result = await getAppDatabase(input.database) - .update(appDeploymentRunsTable) - .set({ - generatedWranglerConfigJson: JSON.stringify({ toml: input.plan.generatedWranglerConfig }), - planJson: JSON.stringify(input.plan), - targetKind, - targetProjectName: targetKind === "cloudflare_pages" ? input.targetName : null, - targetScriptName: targetKind === "cloudflare_worker" ? input.targetName : null, - updatedAt: currentTimestampMs(), - }) - .where( - and( - eq(appDeploymentRunsTable.id, input.runId), - inArray(appDeploymentRunsTable.status, ACTIVE_APP_DEPLOYMENT_RUN_STATUSES), - ), - ) - .run(); - - return getD1ChangeCount(result) > 0; -} - -async function failDeploymentRunIfActive(input: { - database: D1Database; - errorCode: string; - errorMessage: string; - runId: AppDeploymentRunId; -}): Promise { - await getAppDatabase(input.database) - .update(appDeploymentRunsTable) - .set({ - errorCode: input.errorCode, - errorMessage: input.errorMessage, - status: "failed", - updatedAt: currentTimestampMs(), - }) - .where( - and( - eq(appDeploymentRunsTable.id, input.runId), - inArray(appDeploymentRunsTable.status, ACTIVE_APP_DEPLOYMENT_RUN_STATUSES), - ), - ) - .run(); -} - -async function completeDeploymentRun(input: { - database: D1Database; - deployment: AppDeploymentRow; - result: AppDeploymentDeployResult; - run: AppDeploymentRunRow; -}): Promise { - const nowMs = currentTimestampMs(); - const deploymentUpdate = await getAppDatabase(input.database) - .update(appDeploymentsTable) - .set({ - lastSuccessfulUrl: input.result.url, - updatedAt: nowMs, - }) - .where( - and( - eq(appDeploymentsTable.id, input.deployment.id), - eq(appDeploymentsTable.latestRunId, input.run.id), - isNull(appDeploymentsTable.deletedAt), - ), - ) - .run(); - - if (getD1ChangeCount(deploymentUpdate) === 0) { - return false; - } - - const runUpdate = await getAppDatabase(input.database) - .update(appDeploymentRunsTable) - .set({ - errorCode: null, - errorMessage: null, - externalDeploymentId: input.result.externalDeploymentId, - externalProjectId: input.result.externalProjectId, - externalVersionId: input.result.externalVersionId, - status: "success", - updatedAt: nowMs, - url: input.result.url, - }) - .where( - and( - eq(appDeploymentRunsTable.id, input.run.id), - inArray(appDeploymentRunsTable.status, ACTIVE_APP_DEPLOYMENT_RUN_STATUSES), - ), - ) - .run(); - - return getD1ChangeCount(runUpdate) > 0; -} - -async function shouldCompensateDeletedDeployment(input: { - database: D1Database; - deployment: AppDeploymentRow; -}): Promise { - const deletedDeployment = - (await getAppDatabase(input.database) - .select({ id: appDeploymentsTable.id }) - .from(appDeploymentsTable) - .where( - and( - eq(appDeploymentsTable.id, input.deployment.id), - isNotNull(appDeploymentsTable.deletedAt), - ), - ) - .limit(1) - .get()) ?? null; - - if (deletedDeployment === null) { - return false; - } - - const replacement = - (await getAppDatabase(input.database) - .select({ id: appDeploymentsTable.id }) - .from(appDeploymentsTable) - .where( - and( - eq(appDeploymentsTable.appId, input.deployment.appId), - isNull(appDeploymentsTable.deletedAt), - ), - ) - .limit(1) - .get()) ?? null; - - return replacement === null; -} - -async function compensateDeletedDeploymentResources(input: { - bindings: ApiBindings; - cloudflareClient: CloudflareDeploymentClient | null; - deployment: AppDeploymentRow; -}): Promise { - if ( - !(await shouldCompensateDeletedDeployment({ - database: input.bindings.DB, - deployment: input.deployment, - })) - ) { - return; - } - - const deleteFailures = await deleteCloudflareDeploymentResources( - input.cloudflareClient ?? createCloudflareDeploymentClient(input.bindings), - { - hostname: deploymentHostname(input.deployment, input.bindings.MOSOO_APP_DEPLOYMENT_DOMAIN), - resourceName: input.deployment.mosooSubdomain, - }, - ); - - if (deleteFailures.length > 0) { - logCloudflareDeploymentResourceDeleteFailures( - "app-deployment.cloudflare_delete_after_deletion_failed", - deleteFailures, - ); - } -} - -function shouldIncludeSnapshotPath(path: string): boolean { - const fileName = path.split("/").at(-1) ?? path; - - return SNAPSHOT_FILE_NAMES.has(fileName); -} - -async function readRepositorySnapshot( - sandbox: SandboxHandle, - repoDir: string, -): Promise { - const listResult = await sandbox.exec( - `sh -lc ${quoteShellArg(`cd ${quoteShellArg(repoDir)} && find . -type f -print | sort`)}`, - ); - - assertSuccessfulCommand(listResult, "Repository file listing"); - - const files: Record = {}; - const paths = listResult.stdout - .split("\n") - .map((line) => line.trim().replace(/^\.\//u, "")) - .filter((path) => path.length > 0 && shouldIncludeSnapshotPath(path)); - - await Promise.all( - paths.map(async (path) => { - files[path] = (await sandbox.readFile(`${repoDir}/${path}`, { encoding: "utf8" })).content; - }), - ); - - return { files }; -} - -function pagesRoutesFallbackCommands(plan: AppDeploymentPlan, outputDir: string): string[] { - if (plan.routesFallback === null) { - return []; - } - - return [ - `printf '%s\\n' ${quoteShellArg(`/* /${plan.routesFallback} 200`)} > ${quoteShellArg( - `${outputDir}/_redirects`, - )}`, - ]; -} - -async function createPagesArtifactArchive(input: { - plan: AppDeploymentPlan; - prepared: PreparedAppDeploymentRepository; - buildSandbox: SandboxHandle; - workDir: string; -}): Promise { - if (input.plan.outputDir === null) { - throw new AppDeploymentNonRetryableError("Pages deployment plan is missing outputDir."); - } - - const archivePath = `${input.workDir}/artifact.tar`; - const outputDir = `${input.prepared.repoDir}/${input.plan.rootDir}/${input.plan.outputDir}`; - await execChecked( - input.buildSandbox, - [ - `rm -f ${quoteShellArg(archivePath)}`, - ...pagesRoutesFallbackCommands(input.plan, outputDir), - `cd ${quoteShellArg(outputDir)}`, - `find . -type f -print0 | tar --null --no-recursion -cf ${quoteShellArg(archivePath)} -T -`, - ].join(" && "), - "Pages artifact archive", - { retryable: false }, - ); - - return (await input.buildSandbox.readFile(archivePath, { encoding: "base64" })).content; -} - -async function extractPagesArtifactArchive(input: { - archiveBase64: string; - deploySandbox: SandboxHandle; - workDir: string; -}): Promise<{ artifactDir: string; deployDir: string }> { - const artifactDir = `${input.workDir}/artifact`; - const archiveBase64Path = `${input.workDir}/artifact.tar.b64`; - const archivePath = `${input.workDir}/artifact.tar`; - const deployDir = `${input.workDir}/deploy`; - - await execChecked( - input.deploySandbox, - [ - `rm -rf ${quoteShellArg(input.workDir)}`, - `mkdir -p ${quoteShellArg(artifactDir)} ${quoteShellArg(deployDir)}`, - ].join(" && "), - "Pages deploy workspace", - ); - await input.deploySandbox.writeFile(archiveBase64Path, input.archiveBase64); - await execChecked( - input.deploySandbox, - [ - `base64 -d ${quoteShellArg(archiveBase64Path)} > ${quoteShellArg(archivePath)}`, - `tar -xf ${quoteShellArg(archivePath)} -C ${quoteShellArg(artifactDir)}`, - ].join(" && "), - "Pages artifact extraction", - ); - - return { artifactDir, deployDir }; -} - -class SandboxAppDeploymentBuildRunner implements AppDeploymentBuildRunner { - readonly #bindings: ApiBindings; - readonly #cloudflareClient: CloudflareDeploymentClient; - #buildSandbox: SandboxHandle | null = null; - #buildWorkDir: string | null = null; - #runId: AppDeploymentRunId | null = null; - - constructor(bindings: ApiBindings, cloudflareClient: CloudflareDeploymentClient) { - this.#bindings = bindings; - this.#cloudflareClient = cloudflareClient; - } - - async prepare(input: { - deployment: AppDeploymentRow; - run: AppDeploymentRunRow; - }): Promise { - const sandbox = await getRuntimeSubjectKeepAliveHandle( - this.#bindings, - appDeploymentBuildSandboxId(input.run.id), - ); - const workDir = `/tmp/mosoo-app-deployment-build-${input.run.id}`; - const repoDir = `${workDir}/repo`; - const cloneCommand = [ - `rm -rf ${quoteShellArg(workDir)}`, - `mkdir -p ${quoteShellArg(workDir)}`, - `git clone --no-tags --depth 1 ${quoteShellArg(input.deployment.repoUrl)} ${quoteShellArg(repoDir)}`, - `cd ${quoteShellArg(repoDir)}`, - `git fetch --no-tags --depth 1 origin ${quoteShellArg(input.run.sourceCommitSha)}`, - `git checkout --detach ${quoteShellArg(input.run.sourceCommitSha)}`, - ].join(" && "); - - await sandbox.setKeepAlive(true); - await execChecked(sandbox, cloneCommand, "Repository clone"); - - this.#buildSandbox = sandbox; - this.#buildWorkDir = workDir; - this.#runId = input.run.id; - - return { - repoDir, - snapshot: await readRepositorySnapshot(sandbox, repoDir), - }; - } - - async build(input: { - plan: AppDeploymentPlan; - prepared: PreparedAppDeploymentRepository; - }): Promise { - const sandbox = this.#requireBuildSandbox(); - const commands = [input.plan.installCommand, input.plan.buildCommand].filter( - (command): command is string => command !== null, - ); - - if (commands.length === 0) { - return; - } - - const buildSession = await sandbox.createSession({ - cwd: `${input.prepared.repoDir}/${input.plan.rootDir}`, - }); - - await execChecked( - buildSession, - ["unset CLOUDFLARE_API_TOKEN CLOUDFLARE_ACCOUNT_ID CLOUDFLARE_ZONE_ID", ...commands].join( - " && ", - ), - "App deployment build", - { retryable: false }, - ); - } - - async deploy(input: { - deployment: AppDeploymentRow; - envVars: Record; - plan: AppDeploymentPlan; - prepared: PreparedAppDeploymentRepository; - run: AppDeploymentRunRow; - }): Promise { - const buildSandbox = this.#requireBuildSandbox(); - const buildWorkDir = this.#requireBuildWorkDir(); - const targetName = input.deployment.mosooSubdomain; - const domain = this.#bindings.MOSOO_APP_DEPLOYMENT_DOMAIN; - const hostname = deploymentHostname(input.deployment, domain); - - if (input.plan.targetKind === "cloudflare_pages") { - const project = await this.#cloudflareClient.ensurePagesProject({ - branch: input.run.sourceBranch, - projectName: targetName, - }); - const archiveBase64 = await createPagesArtifactArchive({ - buildSandbox, - plan: input.plan, - prepared: input.prepared, - workDir: buildWorkDir, - }); - await this.#destroyBuildSandbox(); - - const deploySandbox = await getRuntimeSubjectKeepAliveHandle( - this.#bindings, - appDeploymentDeploySandboxId(input.run.id), - ); - const deployWorkDir = `/tmp/mosoo-app-deployment-deploy-${input.run.id}`; - await deploySandbox.setKeepAlive(true); - await assertControlledWranglerAvailable(deploySandbox); - const { artifactDir, deployDir } = await extractPagesArtifactArchive({ - archiveBase64, - deploySandbox, - workDir: deployWorkDir, - }); - const deploySession = await deploySandbox.createSession({ - cwd: deployDir, - env: { - CLOUDFLARE_ACCOUNT_ID: this.#bindings.CLOUDFLARE_ACCOUNT_ID, - CLOUDFLARE_API_TOKEN: this.#bindings.CLOUDFLARE_API_TOKEN, - }, - }); - - await execChecked( - deploySession, - [ - "wrangler", - "pages", - "deploy", - quoteShellArg(artifactDir), - "--project-name", - quoteShellArg(targetName), - "--branch", - quoteShellArg(input.run.sourceBranch), - ].join(" "), - "Cloudflare Pages deploy", - ); - - const [latestDeployment, domainResult] = await Promise.all([ - this.#cloudflareClient.getLatestPagesDeployment({ - projectName: targetName, - }), - this.#cloudflareClient.ensurePagesDomain({ - hostname, - projectName: targetName, - }), - ]); - const url = - domainResult.status === "active" - ? deploymentUrl(input.deployment, domain) - : latestDeployment.url; - - if (url === null) { - throw new Error("Cloudflare Pages deployment response did not include a live URL."); - } - - return { - externalDeploymentId: latestDeployment.deploymentId, - externalProjectId: project.projectId, - externalVersionId: null, - url, - }; - } - - if (input.plan.workerEntry === null) { - throw new AppDeploymentNonRetryableError("Worker deployment plan is missing workerEntry."); - } - - if (!WORKER_JS_ENTRY_PATTERN.test(input.plan.workerEntry)) { - throw new AppDeploymentNonRetryableError( - "Worker deployment requires a JavaScript module entry.", - ); - } - - const mainModuleName = input.plan.workerEntry.split("/").at(-1) ?? input.plan.workerEntry; - const scriptContent = ( - await buildSandbox.readFile( - `${input.prepared.repoDir}/${input.plan.rootDir}/${input.plan.workerEntry}`, - { - encoding: "utf8", - }, - ) - ).content; - assertSelfContainedWorkerModule(scriptContent); - await this.#destroyBuildSandbox(); - - const worker = await this.#cloudflareClient.deployWorkerModule({ - compatibilityDate: APP_DEPLOYMENT_COMPATIBILITY_DATE, - mainModuleName, - scriptContent, - scriptName: targetName, - vars: input.envVars, - }); - await this.#cloudflareClient.ensureWorkerRoute({ - hostname, - scriptName: targetName, - }); - await this.#cloudflareClient.ensureWorkerDomain({ - hostname, - scriptName: targetName, - }); - - return { - externalDeploymentId: worker.deploymentId, - externalProjectId: null, - externalVersionId: worker.versionId, - url: deploymentUrl(input.deployment, domain), - }; - } - - async cleanup(): Promise { - if (this.#runId === null) { - return; - } - - await destroyAppDeploymentRunSandboxesBestEffort(this.#bindings, this.#runId); - this.#buildSandbox = null; - this.#buildWorkDir = null; - } - - async #destroyBuildSandbox(): Promise { - if (this.#runId === null || this.#buildSandbox === null) { - return; - } - - await destroyDeploymentSandboxBestEffort( - this.#bindings, - appDeploymentBuildSandboxId(this.#runId), - "app-deployment.build_sandbox_destroy_failed", - ); - this.#buildSandbox = null; - this.#buildWorkDir = null; - } - - #requireBuildSandbox(): SandboxHandle { - if (this.#buildSandbox === null) { - throw new Error("App deployment sandbox was not prepared."); - } - - return this.#buildSandbox; - } - - #requireBuildWorkDir(): string { - if (this.#buildWorkDir === null) { - throw new Error("App deployment work directory was not prepared."); - } - - return this.#buildWorkDir; - } -} - -// Long-lived: the injected URL lives with the deployed Worker and is revoked by -// deleting the deployment (which destroys the Worker) plus the ask endpoint's -// re-check that the agent is still published. See docs/prd/app-deployment.md. -const APP_AGENT_CAPABILITY_TTL_MS = 10 * 365 * 24 * 60 * 60 * 1000; - -// Resolve `.mosoo.toml [[agents]]` bindings to published agents and mint one -// self-authorizing capability URL per binding (fail-fast on an unpublished or -// missing agent). Returns the env var map injected into the deployed Worker. -async function resolveDeploymentEnvVars( - bindings: ApiBindings, - deployment: AppDeploymentRow, - run: AppDeploymentRunRow, - plan: AppDeploymentPlan, -): Promise> { - if (plan.agentBindings.length === 0) { - return {}; - } - - const agentRows = await listAppOwnerAgentRows(bindings.DB, { - appId: deployment.appId, - viewerId: deployment.ownerAccountId, - }); - const resolvable: ResolvableAppAgent[] = agentRows.map((agent) => ({ - id: agent.id, - name: agent.name, - published: agent.status === "published" && agent.liveDeploymentVersionId !== null, - })); - const resolved = resolveAppAgentBindings(plan.agentBindings, resolvable); - const expiresAtMs = currentTimestampMs() + APP_AGENT_CAPABILITY_TTL_MS; - const envVars: Record = {}; - - for (const binding of resolved) { - const token = await mintAppAgentCapabilityToken(bindings.RUNTIME_ACTION_TOKEN_SECRET, { - agentId: parsePlatformId(binding.agentId, "bound Agent ID"), - appId: deployment.appId, - binding: { - env: binding.envVar, - expose: binding.expose, - name: binding.name, - }, - deploymentId: deployment.id, - deploymentRunId: run.id, - exp: expiresAtMs, - }); - envVars[binding.envVar] = boundAgentUrl(bindings.WEB_ORIGIN, token); - } - - return envVars; -} - -export async function dispatchAppDeploymentRun( - bindings: ApiBindings, - input: { appDeploymentRunId: AppDeploymentRunId }, - options: DispatchAppDeploymentRunOptions = {}, -): Promise { - let context = await readCurrentDispatchContext(bindings.DB, input.appDeploymentRunId); - - if (context === null) { - return; - } - - if (!(await updateRunStatus(bindings.DB, input.appDeploymentRunId, "preparing"))) { - return; - } - - const cloudflareClient = - options.cloudflareClient ?? - (options.runner === undefined ? createCloudflareDeploymentClient(bindings) : null); - const runner = - options.runner ?? - new SandboxAppDeploymentBuildRunner( - bindings, - cloudflareClient ?? createCloudflareDeploymentClient(bindings), - ); - let externallyAttemptedDeployment: AppDeploymentRow | null = null; - - try { - const prepared = await runner.prepare(context); - const targetName = context.deployment.mosooSubdomain; - assertRequestedMosooConfigPresent(context.run, prepared.snapshot); - const plan = detectAppDeploymentPlan(prepared.snapshot, { resourceName: targetName }); - - if ( - !(await storeDeploymentPlan({ - database: bindings.DB, - plan, - runId: context.run.id, - targetName, - })) - ) { - return; - } - - let envVars: Record; - try { - envVars = await resolveDeploymentEnvVars(bindings, context.deployment, context.run, plan); - } catch (error) { - if (error instanceof AppAgentBindingResolutionError) { - await failDeploymentRunIfActive({ - database: bindings.DB, - errorCode: error.code, - errorMessage: error.message, - runId: context.run.id, - }); - return; - } - throw error; - } - - if (!(await updateRunStatus(bindings.DB, input.appDeploymentRunId, "building"))) { - return; - } - - await runner.build({ plan, prepared }); - - if (!(await updateRunStatus(bindings.DB, input.appDeploymentRunId, "submitting"))) { - await failDeploymentRunIfActive({ - database: bindings.DB, - errorCode: "deployment_submission_lost", - errorMessage: "Deployment built but the deployment run changed.", - runId: context.run.id, - }); - return; - } - - context = await readCurrentDispatchContext(bindings.DB, input.appDeploymentRunId); - - if (context === null) { - await failDeploymentRunIfActive({ - database: bindings.DB, - errorCode: "deployment_context_lost", - errorMessage: "Deployment context was lost after build.", - runId: input.appDeploymentRunId, - }); - return; - } - - externallyAttemptedDeployment = context.deployment; - const result = await runner.deploy({ ...context, envVars, plan, prepared }); - - if (!(await updateRunStatus(bindings.DB, input.appDeploymentRunId, "submitted"))) { - await failDeploymentRunIfActive({ - database: bindings.DB, - errorCode: "deployment_submission_lost", - errorMessage: "Deployment submitted externally but the deployment run changed.", - runId: context.run.id, - }); - return; - } - - if (!(await updateRunStatus(bindings.DB, input.appDeploymentRunId, "activating"))) { - await failDeploymentRunIfActive({ - database: bindings.DB, - errorCode: "deployment_activation_lost", - errorMessage: "Deployment activated externally but the deployment run changed.", - runId: context.run.id, - }); - return; - } - - const completed = await completeDeploymentRun({ - database: bindings.DB, - deployment: context.deployment, - result, - run: context.run, - }); - - if (!completed) { - await failDeploymentRunIfActive({ - database: bindings.DB, - errorCode: "deployment_completion_lost", - errorMessage: "Deployment completed externally but the App deployment row changed.", - runId: context.run.id, - }); - } - } finally { - if (externallyAttemptedDeployment !== null) { - try { - await compensateDeletedDeploymentResources({ - bindings, - cloudflareClient, - deployment: externallyAttemptedDeployment, - }); - } catch (error) { - logError("app-deployment.cloudflare_delete_after_deletion_check_failed", { - ...createErrorLogContext(error), - deploymentId: externallyAttemptedDeployment.id, - runId: input.appDeploymentRunId, - }); - } - } - - await runner.cleanup?.(); - } -} diff --git a/apps/api/src/modules/apps/application/app-deployment.service.ts b/apps/api/src/modules/apps/application/app-deployment.service.ts deleted file mode 100644 index b87cc9fa..00000000 --- a/apps/api/src/modules/apps/application/app-deployment.service.ts +++ /dev/null @@ -1,843 +0,0 @@ -import type { - AppDeployment, - AppDeploymentRun, - DeleteAppDeploymentInput, - DeployAppInput, -} from "@mosoo/contracts/app"; -import type { ApiCommandId, AppDeploymentRunRow, AppDeploymentRow } from "@mosoo/db"; -import { apiCommandsTable, appDeploymentRunsTable, appDeploymentsTable } from "@mosoo/db"; -import type { AppDeploymentId, AppDeploymentRunId, AppId } from "@mosoo/id"; -import { createPlatformId } from "@mosoo/id"; -import { and, desc, eq, inArray, isNull } from "drizzle-orm"; - -import type { ApiBindings } from "../../../platform/cloudflare/worker-types"; -import { getAppDatabase, getD1ChangeCount } from "../../../platform/db/drizzle"; -import { API_ERROR_CODE, createApiError, validationError } from "../../../platform/errors"; -import { currentTimestampMs, toIsoString } from "../../../time"; -import { - createAppDeploymentRunDispatchDedupeKey, - enqueueAppDeploymentRunDispatchCommand, -} from "../../api-command/application/api-command-enqueue"; -import { API_COMMAND_LEASE_MS } from "../../api-command/application/api-command-ledger"; -import { - APP_DEPLOYMENT_RUN_DISPATCH_MAX_ATTEMPTS, - APP_DEPLOYMENT_RUN_DISPATCH_RETRY_EXHAUSTED_CODE, - createAppDeploymentDispatchRetryExhaustedMessage, -} from "../../api-command/application/api-command-policy"; -import type { AuthenticatedViewer } from "../../auth/application/viewer-auth.service"; -import { ACTIVE_APP_DEPLOYMENT_RUN_STATUSES } from "../domain/app-deployment-lifecycle"; -import { - createCloudflareDeploymentClient, - deleteCloudflareDeploymentResources, - logCloudflareDeploymentResourceDeleteFailures, -} from "./app-deployment-cloudflare-client"; -import type { - CloudflareClientBindings, - CloudflareDeploymentClient, -} from "./app-deployment-cloudflare-client"; -import { destroyAppDeploymentRunSandboxesBestEffort } from "./app-deployment-executor.service"; -import { ensureAppOwnership } from "./app.service"; -import { normalizeLimit } from "./normalize-limit"; - -type AppDeploymentBindings = Pick< - ApiBindings, - "API_COMMAND_QUEUE" | "DB" | "MOSOO_APP_DEPLOYMENT_DOMAIN" ->; -type AppDeploymentDeleteBindings = Pick< - AppDeploymentBindings, - "DB" | "MOSOO_APP_DEPLOYMENT_DOMAIN" -> & - CloudflareClientBindings & - Partial>; - -export type AppDeploymentReadBindings = Pick< - AppDeploymentBindings, - "DB" | "MOSOO_APP_DEPLOYMENT_DOMAIN" ->; - -interface AppDeploymentServiceOptions { - cloudflareClient?: CloudflareDeploymentClient; - fetch?: typeof fetch; - nowMs?: () => number; -} - -type JsonRecord = Record; - -const RUN_LIST_LIMITS = { defaultLimit: 20, maxLimit: 50 }; - -export async function readAppDeploymentForOwnedApp( - bindings: AppDeploymentReadBindings, - appId: AppId, -): Promise { - const deployment = await readActiveDeployment(bindings.DB, appId); - - if (deployment === null) { - return null; - } - - await recoverStaleActiveDeploymentRun(bindings.DB, appId); - - const latestRun = await readLatestDeploymentRun(bindings.DB, appId); - - return toAppDeployment(deployment, latestRun, bindings.MOSOO_APP_DEPLOYMENT_DOMAIN); -} - -export async function getAppDeployment( - bindings: AppDeploymentReadBindings, - viewer: AuthenticatedViewer, - appId: AppId, -): Promise { - await ensureAppOwnership(bindings.DB, viewer.id, appId); - return readAppDeploymentForOwnedApp(bindings, appId); -} - -export async function getAppDeploymentStatus( - bindings: AppDeploymentReadBindings, - viewer: AuthenticatedViewer, - appId: AppId, -): Promise { - await ensureAppOwnership(bindings.DB, viewer.id, appId); - await recoverStaleActiveDeploymentRun(bindings.DB, appId); - - const run = await readLatestDeploymentRun(bindings.DB, appId); - - if (run === null) { - return null; - } - - const deployment = await readDeploymentById(bindings.DB, run.deploymentId); - - return toAppDeploymentRun(run, deployment, bindings.MOSOO_APP_DEPLOYMENT_DOMAIN); -} - -export async function listAppDeploymentRuns( - bindings: AppDeploymentReadBindings, - viewer: AuthenticatedViewer, - appId: AppId, - limit?: number | null, -): Promise { - await ensureAppOwnership(bindings.DB, viewer.id, appId); - await recoverStaleActiveDeploymentRun(bindings.DB, appId); - - const runLimit = normalizeLimit(limit, "limit", RUN_LIST_LIMITS); - const runs = await getAppDatabase(bindings.DB) - .select() - .from(appDeploymentRunsTable) - .where(eq(appDeploymentRunsTable.appId, appId)) - .orderBy(desc(appDeploymentRunsTable.id)) - .limit(runLimit) - .all(); - - if (runs.length === 0) { - return []; - } - - const deploymentsById = await readDeploymentsByIds(bindings.DB, [ - ...new Set(runs.map((run) => run.deploymentId)), - ]); - - return runs.map((run) => { - const deployment = deploymentsById.get(run.deploymentId); - - if (deployment === undefined) { - throw new Error("App deployment row could not be loaded."); - } - - return toAppDeploymentRun(run, deployment, bindings.MOSOO_APP_DEPLOYMENT_DOMAIN); - }); -} - -export async function deployApp( - bindings: AppDeploymentBindings, - viewer: AuthenticatedViewer, - input: DeployAppInput, - options: AppDeploymentServiceOptions = {}, -): Promise { - const configPath = normalizeConfigPath(input.configPath); - const app = await ensureAppOwnership(bindings.DB, viewer.id, input.appId); - const repository = await resolveGitHubRepository( - input.repoUrl, - options.fetch ?? globalThis.fetch, - ); - const activeRun = await readActiveDeploymentRun(bindings.DB, input.appId, { - recoverMissingDispatch: true, - }); - - if (activeRun !== null) { - throw validationError("An App deployment run is already active."); - } - - const nowMs = options.nowMs?.() ?? currentTimestampMs(); - const existingDeployment = await readActiveDeployment(bindings.DB, input.appId); - const deployment = - existingDeployment ?? - ({ - appId: input.appId, - createdAt: nowMs, - defaultBranch: repository.defaultBranch, - deletedAt: null, - id: createPlatformId(), - lastSuccessfulUrl: null, - latestRunId: null, - mosooSubdomain: createMosooSubdomain(input.appId), - ownerAccountId: app.ownerAccountId, - repoName: repository.repoName, - repoOwner: repository.repoOwner, - repoUrl: repository.repoUrl, - sourceKind: "github_public", - updatedAt: nowMs, - } satisfies AppDeploymentRow); - const runId = createPlatformId(); - - if (existingDeployment === null) { - const insertDeploymentResult = await getAppDatabase(bindings.DB) - .insert(appDeploymentsTable) - .values(deployment) - .onConflictDoNothing() - .run(); - - if (getD1ChangeCount(insertDeploymentResult) === 0) { - throw validationError("An App deployment is already active."); - } - } else { - await getAppDatabase(bindings.DB) - .update(appDeploymentsTable) - .set({ - defaultBranch: repository.defaultBranch, - repoName: repository.repoName, - repoOwner: repository.repoOwner, - repoUrl: repository.repoUrl, - updatedAt: nowMs, - }) - .where(eq(appDeploymentsTable.id, deployment.id)) - .run(); - } - - const insertRunResult = await getAppDatabase(bindings.DB) - .insert(appDeploymentRunsTable) - .values({ - appId: input.appId, - createdAt: nowMs, - deploymentId: deployment.id, - errorCode: null, - errorMessage: null, - externalDeploymentId: null, - externalProjectId: null, - externalVersionId: null, - generatedWranglerConfigJson: null, - id: runId, - mosooConfigJson: configPath === null ? null : JSON.stringify({ configPath }), - planJson: null, - sourceBranch: repository.defaultBranch, - sourceCommitSha: repository.sourceCommitSha, - status: "queued", - targetKind: null, - targetProjectName: null, - targetScriptName: null, - updatedAt: nowMs, - url: null, - }) - .onConflictDoNothing() - .run(); - - if (getD1ChangeCount(insertRunResult) === 0) { - throw validationError("An App deployment run is already active."); - } - - let linkRunResult: D1Result; - - try { - linkRunResult = await getAppDatabase(bindings.DB) - .update(appDeploymentsTable) - .set({ latestRunId: runId, updatedAt: nowMs }) - .where(and(eq(appDeploymentsTable.id, deployment.id), isNull(appDeploymentsTable.deletedAt))) - .run(); - } catch (error) { - await markDeploymentRunFailed(bindings.DB, runId, "deployment_run_link_failed", error, nowMs); - throw error; - } - - if (getD1ChangeCount(linkRunResult) === 0) { - await markDeploymentRunFailed( - bindings.DB, - runId, - "deployment_deleted", - new Error("Deployment was deleted before the run was linked."), - nowMs, - ); - throw validationError("App deployment was deleted."); - } - - try { - await enqueueAppDeploymentRunDispatchCommand(bindings, { - appDeploymentRunId: runId, - }); - } catch (error) { - await markDeploymentRunFailed(bindings.DB, runId, "deployment_queue_failed", error, nowMs); - throw error; - } - - const currentDeployment: AppDeploymentRow = { - ...deployment, - defaultBranch: repository.defaultBranch, - latestRunId: runId, - repoName: repository.repoName, - repoOwner: repository.repoOwner, - repoUrl: repository.repoUrl, - updatedAt: nowMs, - }; - const run: AppDeploymentRunRow = { - appId: input.appId, - createdAt: nowMs, - deploymentId: deployment.id, - errorCode: null, - errorMessage: null, - externalDeploymentId: null, - externalProjectId: null, - externalVersionId: null, - generatedWranglerConfigJson: null, - id: runId, - mosooConfigJson: configPath === null ? null : JSON.stringify({ configPath }), - planJson: null, - sourceBranch: repository.defaultBranch, - sourceCommitSha: repository.sourceCommitSha, - status: "queued", - targetKind: null, - targetProjectName: null, - targetScriptName: null, - updatedAt: nowMs, - url: null, - }; - - return toAppDeploymentRun(run, currentDeployment, bindings.MOSOO_APP_DEPLOYMENT_DOMAIN); -} - -export async function deleteAppDeployment( - bindings: AppDeploymentDeleteBindings, - viewer: AuthenticatedViewer, - input: DeleteAppDeploymentInput, - options: AppDeploymentServiceOptions = {}, -): Promise<{ ok: true }> { - await ensureAppOwnership(bindings.DB, viewer.id, input.appId); - - const deployment = await readActiveDeployment(bindings.DB, input.appId); - - if (deployment === null) { - return { ok: true }; - } - - const activeRunIds = await readActiveDeploymentRunIds(bindings.DB, input.appId); - const nowMs = currentTimestampMs(); - - await getAppDatabase(bindings.DB) - .update(appDeploymentRunsTable) - .set({ - errorCode: "deployment_deleted", - errorMessage: "Deployment was deleted.", - status: "failed", - updatedAt: nowMs, - }) - .where( - and( - eq(appDeploymentRunsTable.appId, input.appId), - inArray(appDeploymentRunsTable.status, ACTIVE_APP_DEPLOYMENT_RUN_STATUSES), - ), - ) - .run(); - - await destroyActiveDeploymentRunSandboxes(bindings, activeRunIds); - - const deleteFailures = await deleteCloudflareDeploymentResources( - options.cloudflareClient ?? createCloudflareDeploymentClient(bindings), - { - hostname: createPlannedHost(deployment.mosooSubdomain, bindings.MOSOO_APP_DEPLOYMENT_DOMAIN), - resourceName: deployment.mosooSubdomain, - }, - ); - - if (deleteFailures.length > 0) { - logCloudflareDeploymentResourceDeleteFailures( - "app-deployment.cloudflare_delete_failed", - deleteFailures, - ); - throw createApiError( - API_ERROR_CODE.appDeploymentCleanupFailed, - "Cloudflare deployment cleanup failed. Retry deletion.", - ); - } - - await getAppDatabase(bindings.DB) - .update(appDeploymentsTable) - .set({ - deletedAt: nowMs, - lastSuccessfulUrl: null, - updatedAt: nowMs, - }) - .where(eq(appDeploymentsTable.id, deployment.id)) - .run(); - - return { ok: true }; -} - -async function destroyActiveDeploymentRunSandboxes( - bindings: AppDeploymentDeleteBindings, - runIds: readonly AppDeploymentRunId[], -): Promise { - if (!hasRuntimeSubjectDestroyBinding(bindings)) { - return; - } - - await Promise.all( - runIds.map((runId) => - destroyAppDeploymentRunSandboxesBestEffort(bindings as ApiBindings, runId), - ), - ); -} - -function hasRuntimeSubjectDestroyBinding(bindings: AppDeploymentDeleteBindings): boolean { - return bindings.runtimeSubjectHandleFactory !== undefined || bindings.Sandbox !== undefined; -} - -async function readActiveDeployment( - database: D1Database, - appId: AppId, -): Promise { - return ( - (await getAppDatabase(database) - .select() - .from(appDeploymentsTable) - .where(and(eq(appDeploymentsTable.appId, appId), isNull(appDeploymentsTable.deletedAt))) - .limit(1) - .get()) ?? null - ); -} - -async function readActiveDeploymentRunIds( - database: D1Database, - appId: AppId, -): Promise { - const rows = await getAppDatabase(database) - .select({ id: appDeploymentRunsTable.id }) - .from(appDeploymentRunsTable) - .where( - and( - eq(appDeploymentRunsTable.appId, appId), - inArray(appDeploymentRunsTable.status, ACTIVE_APP_DEPLOYMENT_RUN_STATUSES), - ), - ) - .all(); - - return rows.map((row) => row.id); -} - -async function readDeploymentById( - database: D1Database, - deploymentId: AppDeploymentId, -): Promise { - const row = - (await getAppDatabase(database) - .select() - .from(appDeploymentsTable) - .where(eq(appDeploymentsTable.id, deploymentId)) - .limit(1) - .get()) ?? null; - - if (row === null) { - throw new Error("App deployment row could not be loaded."); - } - - return row; -} - -async function readDeploymentsByIds( - database: D1Database, - deploymentIds: readonly AppDeploymentId[], -): Promise> { - const rows = await getAppDatabase(database) - .select() - .from(appDeploymentsTable) - .where(inArray(appDeploymentsTable.id, [...deploymentIds])) - .all(); - - return new Map(rows.map((row) => [row.id, row])); -} - -async function readLatestDeploymentRun( - database: D1Database, - appId: AppId, -): Promise { - return ( - (await getAppDatabase(database) - .select() - .from(appDeploymentRunsTable) - .where(eq(appDeploymentRunsTable.appId, appId)) - .orderBy(desc(appDeploymentRunsTable.id)) - .limit(1) - .get()) ?? null - ); -} - -async function readActiveDeploymentRun( - database: D1Database, - appId: AppId, - options: { recoverMissingDispatch?: boolean } = {}, -): Promise | null> { - const run = - (await getAppDatabase(database) - .select({ - id: appDeploymentRunsTable.id, - status: appDeploymentRunsTable.status, - updatedAt: appDeploymentRunsTable.updatedAt, - }) - .from(appDeploymentRunsTable) - .where( - and( - eq(appDeploymentRunsTable.appId, appId), - inArray(appDeploymentRunsTable.status, ACTIVE_APP_DEPLOYMENT_RUN_STATUSES), - ), - ) - .limit(1) - .get()) ?? null; - - if (run === null) { - return null; - } - - if (options.recoverMissingDispatch !== true) { - return run; - } - - const nowMs = currentTimestampMs(); - const dispatchCommand = - (await getAppDatabase(database) - .select({ - attemptCount: apiCommandsTable.attemptCount, - claimExpiresAt: apiCommandsTable.claimExpiresAt, - id: apiCommandsTable.id, - lastErrorCode: apiCommandsTable.lastErrorCode, - lastErrorMessage: apiCommandsTable.lastErrorMessage, - status: apiCommandsTable.status, - }) - .from(apiCommandsTable) - .where(eq(apiCommandsTable.dedupeKey, createAppDeploymentRunDispatchDedupeKey(run.id))) - .limit(1) - .get()) ?? null; - - const dispatchRetryExhausted = - dispatchCommand !== null && - (dispatchCommand.status === "queued" || dispatchCommand.status === "running") && - dispatchCommand.attemptCount >= APP_DEPLOYMENT_RUN_DISPATCH_MAX_ATTEMPTS && - dispatchCommand.lastErrorCode !== null; - - if (dispatchRetryExhausted) { - const errorMessage = createAppDeploymentDispatchRetryExhaustedMessage({ - attemptCount: dispatchCommand.attemptCount, - lastErrorMessage: dispatchCommand.lastErrorMessage ?? dispatchCommand.lastErrorCode, - }); - - await markDeploymentRunFailed( - database, - run.id, - APP_DEPLOYMENT_RUN_DISPATCH_RETRY_EXHAUSTED_CODE, - new Error(errorMessage), - nowMs, - ); - await markDeploymentDispatchCommandFailed(database, dispatchCommand.id, { - errorCode: APP_DEPLOYMENT_RUN_DISPATCH_RETRY_EXHAUSTED_CODE, - errorMessage, - nowMs, - }); - - return null; - } - - if ( - dispatchCommand?.status === "queued" || - (dispatchCommand?.status === "running" && - dispatchCommand.claimExpiresAt !== null && - dispatchCommand.claimExpiresAt > nowMs) - ) { - return run; - } - - if (nowMs - run.updatedAt < API_COMMAND_LEASE_MS) { - return run; - } - - const staleDispatch = - dispatchCommand?.status === "running" && - dispatchCommand.claimExpiresAt !== null && - dispatchCommand.claimExpiresAt <= nowMs; - - await markDeploymentRunFailed( - database, - run.id, - staleDispatch ? "deployment_dispatch_expired" : "deployment_dispatch_missing", - new Error( - staleDispatch - ? "Deployment dispatch claim expired before completion." - : "Deployment dispatch command is missing.", - ), - nowMs, - ); - - return null; -} - -async function markDeploymentDispatchCommandFailed( - database: D1Database, - commandId: ApiCommandId, - input: { errorCode: string; errorMessage: string; nowMs: number }, -): Promise { - await getAppDatabase(database) - .update(apiCommandsTable) - .set({ - claimExpiresAt: null, - claimOwner: null, - completedAt: input.nowMs, - lastErrorCode: input.errorCode, - lastErrorMessage: input.errorMessage, - status: "failed", - updatedAt: input.nowMs, - }) - .where( - and( - eq(apiCommandsTable.id, commandId), - inArray(apiCommandsTable.status, ["queued", "running"]), - ), - ) - .run(); -} - -async function recoverStaleActiveDeploymentRun(database: D1Database, appId: AppId): Promise { - await readActiveDeploymentRun(database, appId, { recoverMissingDispatch: true }); -} - -async function markDeploymentRunFailed( - database: D1Database, - runId: AppDeploymentRunId, - errorCode: string, - error: unknown, - nowMs: number, -): Promise { - await getAppDatabase(database) - .update(appDeploymentRunsTable) - .set({ - errorCode, - errorMessage: error instanceof Error ? error.message : "Deployment queue failed.", - status: "failed", - updatedAt: nowMs, - }) - .where( - and( - eq(appDeploymentRunsTable.id, runId), - inArray(appDeploymentRunsTable.status, ACTIVE_APP_DEPLOYMENT_RUN_STATUSES), - ), - ) - .run(); -} - -function toAppDeployment( - row: AppDeploymentRow, - latestRun: AppDeploymentRunRow | null, - domain: string, -): AppDeployment { - return { - appId: row.appId, - createdAt: toIsoString(row.createdAt), - defaultBranch: row.defaultBranch, - id: row.id, - latestRun: latestRun === null ? null : toAppDeploymentRun(latestRun, row, domain), - liveUrl: row.lastSuccessfulUrl, - plannedUrl: createPlannedUrl(row.mosooSubdomain, domain), - repoName: row.repoName, - repoOwner: row.repoOwner, - repoUrl: row.repoUrl, - updatedAt: toIsoString(row.updatedAt), - }; -} - -function toAppDeploymentRun( - row: AppDeploymentRunRow, - deployment: AppDeploymentRow, - domain: string, -): AppDeploymentRun { - return { - appId: row.appId, - createdAt: toIsoString(row.createdAt), - deploymentId: row.deploymentId, - errorCode: row.errorCode, - errorMessage: row.errorMessage, - id: row.id, - liveUrl: row.status === "success" && deployment.deletedAt === null ? row.url : null, - plannedUrl: createPlannedUrl(deployment.mosooSubdomain, domain), - sourceBranch: row.sourceBranch, - sourceCommitSha: row.sourceCommitSha, - status: row.status, - targetKind: row.targetKind, - updatedAt: toIsoString(row.updatedAt), - }; -} - -function createMosooSubdomain(appId: AppId): string { - return `app-${appId.toLowerCase()}`; -} - -function createPlannedUrl(subdomain: string, domain: string): string { - return `https://${createPlannedHost(subdomain, domain)}`; -} - -function createPlannedHost(subdomain: string, domain: string): string { - return `${subdomain}.${domain}`; -} - -function normalizeConfigPath(value: string | null | undefined): ".mosoo.toml" | null { - if (value === null || value === undefined) { - return null; - } - - if (value !== ".mosoo.toml") { - throw validationError("configPath must be .mosoo.toml when provided."); - } - - return value; -} - -async function resolveGitHubRepository( - repoUrl: string, - fetcher: typeof fetch, -): Promise<{ - defaultBranch: string; - repoName: string; - repoOwner: string; - repoUrl: string; - sourceCommitSha: string; -}> { - const parsed = parseGitHubRepoUrl(repoUrl); - const repoJson = await fetchGitHubJson( - fetcher, - `https://api.github.com/repos/${parsed.owner}/${parsed.repo}`, - "GitHub repository", - ); - - if (readBoolean(repoJson, "private", "GitHub repository")) { - throw validationError("GitHub repository must be public."); - } - - const defaultBranch = readNonEmptyString(repoJson, "default_branch", "GitHub repository"); - const repoOwner = readGitHubOwner(repoJson["owner"]) ?? parsed.owner; - const repoName = readOptionalString(repoJson, "name") ?? parsed.repo; - const cloneUrl = - readOptionalString(repoJson, "clone_url") ?? `https://github.com/${repoOwner}/${repoName}.git`; - const branchJson = await fetchGitHubJson( - fetcher, - `https://api.github.com/repos/${repoOwner}/${repoName}/branches/${encodeURIComponent(defaultBranch)}`, - "GitHub default branch", - ); - const commit = requireRecord(branchJson["commit"], "GitHub default branch commit"); - - return { - defaultBranch, - repoName, - repoOwner, - repoUrl: cloneUrl, - sourceCommitSha: readNonEmptyString(commit, "sha", "GitHub default branch commit"), - }; -} - -async function fetchGitHubJson( - fetcher: typeof fetch, - url: string, - label: string, -): Promise { - const response = await fetcher(url, { - headers: { - Accept: "application/vnd.github+json", - "User-Agent": "mosoo-api", - "X-GitHub-Api-Version": "2022-11-28", - }, - }); - - if (response.status === 404) { - throw validationError(`${label} was not found.`); - } - - if (!response.ok) { - throw validationError(`${label} could not be checked.`); - } - - return requireRecord(await response.json(), label); -} - -function parseGitHubRepoUrl(repoUrl: string): { owner: string; repo: string } { - let url: URL; - - try { - url = new URL(repoUrl); - } catch { - throw validationError("repoUrl must be a GitHub HTTPS repository URL."); - } - - if (url.protocol !== "https:" || url.hostname !== "github.com") { - throw validationError("repoUrl must be a GitHub HTTPS repository URL."); - } - - const segments = url.pathname.split("/").filter((segment) => segment.length > 0); - - if (segments.length !== 2 || url.search !== "" || url.hash !== "") { - throw validationError("repoUrl must point to a GitHub repository root."); - } - - const owner = segments[0] ?? ""; - const repo = (segments[1] ?? "").replace(/\.git$/u, ""); - - if (!/^[A-Za-z0-9.-]+$/u.test(owner) || !/^[A-Za-z0-9._-]+$/u.test(repo)) { - throw validationError("repoUrl must point to a valid GitHub repository."); - } - - return { owner, repo }; -} - -function requireRecord(value: unknown, label: string): JsonRecord { - if (typeof value !== "object" || value === null || Array.isArray(value)) { - throw validationError(`${label} response is invalid.`); - } - - return value as JsonRecord; -} - -function readNonEmptyString(record: JsonRecord, field: string, label: string): string { - const value = record[field]; - - if (typeof value !== "string" || value.trim().length === 0) { - throw validationError(`${label} response is invalid.`); - } - - return value; -} - -function readOptionalString(record: JsonRecord, field: string): string | null { - const value = record[field]; - - return typeof value === "string" && value.length > 0 ? value : null; -} - -function readBoolean(record: JsonRecord, field: string, label: string): boolean { - const value = record[field]; - - if (typeof value !== "boolean") { - throw validationError(`${label} response is invalid.`); - } - - return value; -} - -function readGitHubOwner(value: unknown): string | null { - if (typeof value !== "object" || value === null || Array.isArray(value)) { - return null; - } - - const login = (value as JsonRecord)["login"]; - - return typeof login === "string" && login.length > 0 ? login : null; -} diff --git a/apps/api/src/modules/apps/application/app-overview.service.ts b/apps/api/src/modules/apps/application/app-overview.service.ts index e6834253..19bbe3cb 100644 --- a/apps/api/src/modules/apps/application/app-overview.service.ts +++ b/apps/api/src/modules/apps/application/app-overview.service.ts @@ -1,15 +1,12 @@ import type { AppOverview, AppOverviewAgent, - AppOverviewBoundAgent, AppOverviewProviderCredential, ControlPlaneOverview, } from "@mosoo/contracts/app"; -import { appDeploymentRunsTable } from "@mosoo/db"; import type { AppId } from "@mosoo/id"; -import { and, desc, eq, isNotNull } from "drizzle-orm"; -import { getAppDatabase } from "../../../platform/db/drizzle"; +import { validationError } from "../../../platform/errors"; import { toIsoString } from "../../../time"; import { listAppOwnerAgentRowsPage } from "../../agents/application/agent-repository"; import { toAgentRuntimeModelProjection } from "../../agents/application/agent-runtime-model-identity"; @@ -22,13 +19,22 @@ import { listAppVendorCredentialRowsPage, } from "../../vendor-credentials/application/vendor-credential.repository"; import type { VendorCredentialRow } from "../../vendor-credentials/application/vendor-credential.types"; -import type { AppDeploymentAgentBinding } from "./app-deployment-detector"; -import type { AppDeploymentReadBindings } from "./app-deployment.service"; -import { readAppDeploymentForOwnedApp } from "./app-deployment.service"; import { ensureAppOwnership, listOrganizationAppsPage, toAppSummary } from "./app.service"; -import { normalizeLimit } from "./normalize-limit"; -const OVERVIEW_LIMITS = { defaultLimit: 50, maxLimit: 100 }; +const DEFAULT_OVERVIEW_LIMIT = 50; +const MAX_OVERVIEW_LIMIT = 100; + +function normalizeOverviewLimit(value: number | null | undefined, field: string): number { + if (value === null || value === undefined) { + return DEFAULT_OVERVIEW_LIMIT; + } + + if (!Number.isInteger(value) || value < 1) { + throw validationError(`${field} must be a positive integer.`); + } + + return Math.min(value, MAX_OVERVIEW_LIMIT); +} function toOverviewAgent(row: AgentRow): AppOverviewAgent { const runtimeModel = toAgentRuntimeModelProjection(row); @@ -60,82 +66,8 @@ function toOverviewProviderCredential(row: VendorCredentialRow): AppOverviewProv }; } -// The bound agents shown on the overview come from the deployed manifest's -// `.mosoo.toml [[agents]]` (persisted in the latest run's planJson). We surface -// only the env var NAME each agent injects, never the capability URL value. -async function readDeploymentAgentBindings( - bindings: AppDeploymentReadBindings, - deployment: AppOverview["deployment"], -): Promise { - const latestRunId = deployment?.latestRun?.id ?? null; - const deploymentId = deployment?.id ?? null; - - if (deploymentId === null) { - return []; - } - - const latestRunPlan = - latestRunId === null - ? null - : ((await getAppDatabase(bindings.DB) - .select({ planJson: appDeploymentRunsTable.planJson }) - .from(appDeploymentRunsTable) - .where(eq(appDeploymentRunsTable.id, latestRunId)) - .limit(1) - .get()) ?? null); - - let planJson = latestRunPlan?.planJson ?? null; - - if (planJson === null) { - const latestParsedPlan = - (await getAppDatabase(bindings.DB) - .select({ planJson: appDeploymentRunsTable.planJson }) - .from(appDeploymentRunsTable) - .where( - and( - eq(appDeploymentRunsTable.deploymentId, deploymentId), - isNotNull(appDeploymentRunsTable.planJson), - ), - ) - .orderBy(desc(appDeploymentRunsTable.id)) - .limit(1) - .get()) ?? null; - - planJson = latestParsedPlan?.planJson ?? null; - } - - if (planJson === null) { - return []; - } - - try { - const plan = JSON.parse(planJson) as { agentBindings?: AppDeploymentAgentBinding[] }; - return Array.isArray(plan.agentBindings) ? plan.agentBindings : []; - } catch { - return []; - } -} - -function toBoundAgent( - binding: AppDeploymentAgentBinding, - agentsByName: Map, -): AppOverviewBoundAgent | null { - const agent = agentsByName.get(binding.name); - - if (agent === undefined) { - return null; - } - - return { - agentId: agent.id, - envVar: binding.env, - expose: binding.expose, - name: binding.name, - }; -} - export async function getAppOverview( - bindings: AppDeploymentReadBindings, + database: D1Database, viewer: AuthenticatedViewer, input: { agentLimit?: number | null; @@ -143,26 +75,20 @@ export async function getAppOverview( credentialLimit?: number | null; }, ): Promise { - const agentLimit = normalizeLimit(input.agentLimit, "agentLimit", OVERVIEW_LIMITS); - const credentialLimit = normalizeLimit(input.credentialLimit, "credentialLimit", OVERVIEW_LIMITS); - const app = await ensureAppOwnership(bindings.DB, viewer.id, input.appId); + const agentLimit = normalizeOverviewLimit(input.agentLimit, "agentLimit"); + const credentialLimit = normalizeOverviewLimit(input.credentialLimit, "credentialLimit"); + const app = await ensureAppOwnership(database, viewer.id, input.appId); - const [agentRows, credentialRows, credentialCounts, deployment] = await Promise.all([ - listAppOwnerAgentRowsPage(bindings.DB, { + const [agentRows, credentialRows, credentialCounts] = await Promise.all([ + listAppOwnerAgentRowsPage(database, { appId: input.appId, limit: agentLimit + 1, viewerId: viewer.id, }), - listAppVendorCredentialRowsPage(bindings.DB, input.appId, credentialLimit + 1), - listAppVendorCredentialCountsByVendor(bindings.DB, input.appId), - readAppDeploymentForOwnedApp(bindings, input.appId), + listAppVendorCredentialRowsPage(database, input.appId, credentialLimit + 1), + listAppVendorCredentialCountsByVendor(database, input.appId), ]); - const agentsByName = new Map(agentRows.map((agent) => [agent.name, agent])); - const boundAgents = (await readDeploymentAgentBindings(bindings, deployment)) - .map((binding) => toBoundAgent(binding, agentsByName)) - .filter((agent): agent is AppOverviewBoundAgent => agent !== null); - return { agents: { hasMore: agentRows.length > agentLimit, @@ -170,8 +96,6 @@ export async function getAppOverview( limit: agentLimit, }, app: toAppSummary(app), - boundAgents, - deployment, providerCredentials: { byVendor: credentialCounts, configuredCount: credentialCounts.reduce((sum, row) => sum + row.count, 0), @@ -183,7 +107,7 @@ export async function getAppOverview( } export async function getControlPlaneOverview( - bindings: AppDeploymentReadBindings, + database: D1Database, viewer: AuthenticatedViewer, input: { agentLimit?: number | null; @@ -191,8 +115,8 @@ export async function getControlPlaneOverview( credentialLimit?: number | null; } = {}, ): Promise { - const appLimit = normalizeLimit(input.appLimit, "appLimit", OVERVIEW_LIMITS); - const activeOrganization = await resolveActiveOrganization(bindings.DB, viewer.id); + const appLimit = normalizeOverviewLimit(input.appLimit, "appLimit"); + const activeOrganization = await resolveActiveOrganization(database, viewer.id); if (activeOrganization === null) { return { @@ -205,7 +129,7 @@ export async function getControlPlaneOverview( }; } - const apps = await listOrganizationAppsPage(bindings.DB, viewer, { + const apps = await listOrganizationAppsPage(database, viewer, { limit: appLimit + 1, organizationId: activeOrganization.id, }); @@ -216,7 +140,7 @@ export async function getControlPlaneOverview( hasMore: apps.length > appLimit, items: await Promise.all( apps.slice(0, appLimit).map((app) => - getAppOverview(bindings, viewer, { + getAppOverview(database, viewer, { ...(input.agentLimit === undefined ? {} : { agentLimit: input.agentLimit }), appId: app.id, ...(input.credentialLimit === undefined diff --git a/apps/api/src/modules/apps/application/normalize-limit.ts b/apps/api/src/modules/apps/application/normalize-limit.ts deleted file mode 100644 index a9e047ff..00000000 --- a/apps/api/src/modules/apps/application/normalize-limit.ts +++ /dev/null @@ -1,23 +0,0 @@ -import { validationError } from "../../../platform/errors"; - -/** - * Validate and clamp a caller-supplied page limit: null/undefined falls back - * to the default, non-positive or non-integer values are rejected, and values - * over the cap are clamped rather than rejected — the shared semantics for - * every apps-module list query. - */ -export function normalizeLimit( - value: number | null | undefined, - field: string, - { defaultLimit, maxLimit }: { defaultLimit: number; maxLimit: number }, -): number { - if (value === null || value === undefined) { - return defaultLimit; - } - - if (!Number.isInteger(value) || value < 1) { - throw validationError(`${field} must be a positive integer.`); - } - - return Math.min(value, maxLimit); -} diff --git a/apps/api/src/modules/apps/domain/app-deployment-lifecycle.ts b/apps/api/src/modules/apps/domain/app-deployment-lifecycle.ts deleted file mode 100644 index 625394ee..00000000 --- a/apps/api/src/modules/apps/domain/app-deployment-lifecycle.ts +++ /dev/null @@ -1,10 +0,0 @@ -import type { AppDeploymentRunStatus } from "@mosoo/contracts/app"; - -export const ACTIVE_APP_DEPLOYMENT_RUN_STATUSES = [ - "queued", - "preparing", - "building", - "submitting", - "submitted", - "activating", -] as const satisfies readonly AppDeploymentRunStatus[]; diff --git a/apps/api/src/modules/apps/graphql/app-graphql.ts b/apps/api/src/modules/apps/graphql/app-graphql.ts index b3d07611..f831757e 100644 --- a/apps/api/src/modules/apps/graphql/app-graphql.ts +++ b/apps/api/src/modules/apps/graphql/app-graphql.ts @@ -1,14 +1,8 @@ -import type { DeployAppInput } from "@mosoo/contracts/app"; import { parsePlatformId } from "@mosoo/id"; import type { OrganizationId, AppId } from "@mosoo/id"; import type { GraphQLModule } from "../../../adapters/graphql/graphql-module"; import { appGraphQLSpec } from "../../../adapters/graphql/graphql-module-specs"; -import { - deleteAppDeployment, - getAppDeploymentStatus, - listAppDeploymentRuns, -} from "../application/app-deployment.service"; import { getAppOverview, getControlPlaneOverview } from "../application/app-overview.service"; import { createApp } from "../application/app-provisioning.service"; import { listOrganizationApps, renameApp } from "../application/app.service"; @@ -17,11 +11,6 @@ interface OrganizationIdArgs { organizationId: OrganizationId; } -interface AppDeploymentRunListArgs { - appId: string; - limit?: number | null; -} - interface AppOverviewArgs { agentLimit?: number | null; appId: string; @@ -41,14 +30,6 @@ interface CreateAppArgs { }; } -interface DeployAppArgs { - input: DeployAppInput; -} - -interface DeleteAppDeploymentArgs { - input: Parameters[2]; -} - interface RenameAppArgs { input: Parameters[2]; } @@ -62,29 +43,20 @@ export const appGraphQLModule = { authenticatedMutationResolvers: { createApp: async (_parent, args: CreateAppArgs, context) => createApp(context.bindings, context.viewer, args.input), - deleteAppDeployment: async (_parent, args: DeleteAppDeploymentArgs, context) => - deleteAppDeployment(context.bindings, context.viewer, args.input), - deployApp: async (_parent, _args: DeployAppArgs, _context) => { - throw new Error("App deployment creation is frozen for product retirement."); - }, renameApp: async (_parent, args: RenameAppArgs, context) => renameApp(context.bindings.DB, context.viewer, args.input), }, authenticatedQueryResolvers: { - appDeploymentRunList: async (_parent, args: AppDeploymentRunListArgs, context) => - listAppDeploymentRuns(context.bindings, context.viewer, parseAppId(args.appId), args.limit), - appDeploymentStatus: async (_parent, args: { appId: string }, context) => - getAppDeploymentStatus(context.bindings, context.viewer, parseAppId(args.appId)), appList: async (_parent, args: OrganizationIdArgs, context) => listOrganizationApps(context.bindings.DB, context.viewer, args.organizationId), appOverview: async (_parent, args: AppOverviewArgs, context) => - getAppOverview(context.bindings, context.viewer, { + getAppOverview(context.bindings.DB, context.viewer, { ...(args.agentLimit === undefined ? {} : { agentLimit: args.agentLimit }), appId: parseAppId(args.appId), ...(args.credentialLimit === undefined ? {} : { credentialLimit: args.credentialLimit }), }), controlPlaneOverview: async (_parent, args: ControlPlaneOverviewArgs, context) => - getControlPlaneOverview(context.bindings, context.viewer, { + getControlPlaneOverview(context.bindings.DB, context.viewer, { ...(args.agentLimit === undefined ? {} : { agentLimit: args.agentLimit }), ...(args.appLimit === undefined ? {} : { appLimit: args.appLimit }), ...(args.credentialLimit === undefined ? {} : { credentialLimit: args.credentialLimit }), diff --git a/apps/api/src/modules/auth/application/personal-access-token.service.ts b/apps/api/src/modules/auth/application/personal-access-token.service.ts index fce8273b..ab8c70ac 100644 --- a/apps/api/src/modules/auth/application/personal-access-token.service.ts +++ b/apps/api/src/modules/auth/application/personal-access-token.service.ts @@ -53,7 +53,7 @@ function createTokenValue(): string { return `${TOKEN_VALUE_PREFIX}${toBase64Url(bytes)}`; } -export function isPersonalAccessTokenValue(tokenValue: string): boolean { +function isPersonalAccessTokenValue(tokenValue: string): boolean { return ( tokenValue.startsWith(TOKEN_VALUE_PREFIX) || tokenValue.startsWith(LEGACY_TOKEN_VALUE_PREFIX) ); diff --git a/apps/api/src/modules/auth/application/public-api-caller.service.ts b/apps/api/src/modules/auth/application/public-api-caller.service.ts deleted file mode 100644 index 7f5916c0..00000000 --- a/apps/api/src/modules/auth/application/public-api-caller.service.ts +++ /dev/null @@ -1,106 +0,0 @@ -import { accountsTable } from "@mosoo/db"; -import type { AccountId, AppDeploymentId, PersonalAccessTokenId } from "@mosoo/id"; -import { eq } from "drizzle-orm"; - -import { getAppDatabase } from "../../../platform/db/drizzle"; -import type { AppAgentCapabilityClaims } from "../../public-api/app-agent-capability"; -import { - authenticatePersonalAccessToken, - isPersonalAccessTokenValue, - readBearerToken, -} from "./personal-access-token.service"; -import type { PersonalAccessTokenCaller } from "./personal-access-token.service"; -import type { AuthenticatedViewer } from "./viewer-auth.service"; - -type AccessTokenCredentialSubjectId = `human:${AccountId}`; -type DeploymentCapabilityCredentialSubjectId = `deployment:${AppDeploymentId}`; - -export interface AccessTokenPublicApiCaller { - credentialSubjectId: AccessTokenCredentialSubjectId; - kind: "access_token"; - tokenId: PersonalAccessTokenId; - tokenLabel: string; - viewer: AuthenticatedViewer; -} - -/** - * A deployed App calling through its injected bound Agent capability. The - * caller acts as the App owner (`viewer`) but only inside the App, Agent - * binding, and Deployment named by the verified claims; thread and file - * admission narrows every read and write to that scope. - */ -export interface DeploymentCapabilityPublicApiCaller { - capability: AppAgentCapabilityClaims; - credentialSubjectId: DeploymentCapabilityCredentialSubjectId; - kind: "deployment_capability"; - viewer: AuthenticatedViewer; -} - -export type PublicApiCaller = AccessTokenPublicApiCaller | DeploymentCapabilityPublicApiCaller; - -function toAccessTokenCredentialSubjectId(accountId: AccountId): AccessTokenCredentialSubjectId { - return `human:${accountId}`; -} - -export function toDeploymentCapabilityCredentialSubjectId( - deploymentId: AppDeploymentId, -): DeploymentCapabilityCredentialSubjectId { - return `deployment:${deploymentId}`; -} - -function toAccessTokenCaller(caller: PersonalAccessTokenCaller): AccessTokenPublicApiCaller { - return { - credentialSubjectId: toAccessTokenCredentialSubjectId(caller.viewer.id), - kind: "access_token", - tokenId: caller.tokenId, - tokenLabel: caller.tokenLabel, - viewer: caller.viewer, - }; -} - -export async function getAccountViewer( - database: D1Database, - accountId: AccountId, -): Promise { - const row = - (await getAppDatabase(database) - .select({ - email: accountsTable.email, - email_verified: accountsTable.emailVerified, - id: accountsTable.id, - image_url: accountsTable.image, - name: accountsTable.name, - }) - .from(accountsTable) - .where(eq(accountsTable.id, accountId)) - .limit(1) - .get()) ?? null; - - if (!row) { - return null; - } - - return { - email: row.email, - emailVerified: row.email_verified, - id: row.id, - imageUrl: row.image_url, - name: row.name, - }; -} - -export async function authenticatePublicApiCaller( - database: D1Database, - tokenValue: string, -): Promise { - if (isPersonalAccessTokenValue(tokenValue)) { - const accessTokenCaller = await authenticatePersonalAccessToken(database, tokenValue); - return accessTokenCaller === null ? null : toAccessTokenCaller(accessTokenCaller); - } - - return null; -} - -export function readPublicApiBearerToken(request: Request): string | null { - return readBearerToken(request); -} diff --git a/apps/api/src/modules/auth/application/viewer-auth.service.ts b/apps/api/src/modules/auth/application/viewer-auth.service.ts index 2a5224fe..19135168 100644 --- a/apps/api/src/modules/auth/application/viewer-auth.service.ts +++ b/apps/api/src/modules/auth/application/viewer-auth.service.ts @@ -1,9 +1,34 @@ +import { accountsTable } from "@mosoo/db"; +import type { AccountId } from "@mosoo/id"; +import { eq } from "drizzle-orm"; + import type { ApiBindings } from "../../../platform/cloudflare/worker-types"; +import { getAppDatabase } from "../../../platform/db/drizzle"; import type { AuthenticatedViewer } from "../domain/authenticated-viewer"; import { authenticatePersonalAccessToken, readBearerToken } from "./personal-access-token.service"; export type { AuthenticatedViewer }; +export async function getAccountViewer( + database: D1Database, + accountId: AccountId, +): Promise { + return ( + (await getAppDatabase(database) + .select({ + email: accountsTable.email, + emailVerified: accountsTable.emailVerified, + id: accountsTable.id, + imageUrl: accountsTable.image, + name: accountsTable.name, + }) + .from(accountsTable) + .where(eq(accountsTable.id, accountId)) + .limit(1) + .get()) ?? null + ); +} + function isSessionAuthConfigured(bindings: Pick): boolean { return Boolean(bindings.BETTER_AUTH_SECRET?.trim()); } diff --git a/apps/api/src/modules/public-api/app-agent-bound-ask.service.ts b/apps/api/src/modules/public-api/app-agent-bound-ask.service.ts deleted file mode 100644 index 218b769f..00000000 --- a/apps/api/src/modules/public-api/app-agent-bound-ask.service.ts +++ /dev/null @@ -1,449 +0,0 @@ -/** - * The blocking bound-agent ask endpoint (PM decision #2): a deployed App POSTs - * the injected self-authorizing capability URL with `{ message | input }` and - * gets the Agent's FINAL reply back in one call — no PAT. - * - * Flow: verify the capability token -> re-check the Agent is still published -> - * resolve the App owner account and run as that owner (the App owns the Agent, - * so we build the session from the owner viewer WITHOUT a PAT caller, reusing - * `createAgentSession` + `queueSessionRun`) -> wait (bounded) for the run to - * reach a terminal state -> return the final output text. - */ - -import type { SessionSummary } from "@mosoo/contracts/session"; -import { sessionEventsTable, sessionRunsTable, sessionsTable } from "@mosoo/db"; -import { parsePlatformId } from "@mosoo/id"; -import type { AccountId, AgentId, AppId, SessionId, SessionRunId } from "@mosoo/id"; -import { and, asc, eq } from "drizzle-orm"; - -import { createErrorLogContext, logError } from "../../platform/cloudflare/logger"; -import type { ApiBindings } from "../../platform/cloudflare/worker-types"; -import { getAppDatabase } from "../../platform/db/drizzle"; -import type { AuthenticatedViewer } from "../auth/application/viewer-auth.service"; -import { - createAgentSession, - queueSessionRun, - SessionRunCreationGuardRejectedError, -} from "../runtime/application/session-run.service"; -import { getActiveSessionRunSummary } from "../runtime/infrastructure/session-runs/session-run-read.repository"; -import { getSessionRunSummary } from "../runtime/infrastructure/session-runs/session-run-store.repository"; -import { getSessionSummaryForCreator } from "../sessions/application/session-summary-query.service"; -import { selectBoundAgentReply, waitForTerminalRun } from "./app-agent-bound-call"; -import type { BoundAgentCallInput } from "./app-agent-bound-call"; -import { - beginBoundAgentCallIdempotency, - bindBoundAgentCallIdempotencyRun, - hashBoundAgentCallIdempotencyBody, - hashBoundAgentCallIdempotencySubject, -} from "./app-agent-bound-idempotency.service"; -import type { AppAgentCapabilityClaims } from "./app-agent-capability"; -import { - admitDeploymentCapability, - createDeploymentCapabilityRunAdmission, - deploymentCapabilityRateLimitKey, - ensureDeploymentCapabilityAuthorized, -} from "./deployment-capability-caller.service"; -import { enforcePublicApiRateLimit } from "./public-api-rate-limit.service"; -import { readPublicThreadRunFinalOutput } from "./public-thread-events"; -import { cleanupFailedThreadCreation } from "./public-thread-store"; - -const BOUND_AGENT_WAIT_TIMEOUT_MS = 25_000; -const BOUND_AGENT_WAIT_POLL_INTERVAL_MS = 1_000; - -export interface CreateBoundAgentThreadAndWaitRequest { - bindings: ApiBindings; - executionContext: Pick | null; - idempotencyKey?: string | null; - input: BoundAgentCallInput; - requestUrl: string; - token: string; -} - -export interface BoundAgentCallResponse { - reply: string; - runId: SessionRunId; -} - -function delay(ms: number): Promise { - return new Promise((resolve) => { - setTimeout(resolve, ms); - }); -} - -async function loadRecoverableBoundSession(input: { - agentId: AgentId; - appId: AppId; - database: D1Database; - ownerViewer: AuthenticatedViewer; - sessionId: SessionId; -}): Promise { - const ownerId = parsePlatformId(input.ownerViewer.id, "bound Agent owner id"); - const existing = - (await getAppDatabase(input.database) - .select({ id: sessionsTable.id }) - .from(sessionsTable) - .where( - and( - eq(sessionsTable.id, input.sessionId), - eq(sessionsTable.agentId, input.agentId), - eq(sessionsTable.appId, input.appId), - eq(sessionsTable.creatorAccountId, ownerId), - ), - ) - .limit(1) - .get()) ?? null; - - if (existing === null) { - return null; - } - - return getSessionSummaryForCreator(input.database, ownerId, { - appId: input.appId, - sessionId: input.sessionId, - }); -} - -async function ensureRecoverableBoundSession(input: { - agentId: AgentId; - appId: AppId; - bindings: ApiBindings; - executionContext: Pick | null; - ownerViewer: AuthenticatedViewer; - sessionId: SessionId; -}): Promise { - const existing = await loadRecoverableBoundSession({ - agentId: input.agentId, - appId: input.appId, - database: input.bindings.DB, - ownerViewer: input.ownerViewer, - sessionId: input.sessionId, - }); - - if (existing !== null) { - return existing; - } - - try { - return await createAgentSession({ - bindings: input.bindings, - executionContext: input.executionContext, - input: { - agentId: input.agentId, - appId: input.appId, - type: "ui", - }, - options: { - accessViewer: input.ownerViewer, - metadata: null, - sessionId: input.sessionId, - }, - viewer: input.ownerViewer, - }); - } catch (error) { - const recovered = await loadRecoverableBoundSession({ - agentId: input.agentId, - appId: input.appId, - database: input.bindings.DB, - ownerViewer: input.ownerViewer, - sessionId: input.sessionId, - }); - - if (recovered !== null) { - return recovered; - } - - throw error; - } -} - -async function findRecoverableBoundRun( - database: D1Database, - input: { - clientRequestId: string | null; - recoverableRunId?: SessionRunId; - session: SessionSummary; - }, -): Promise<{ runId: SessionRunId; sessionId: SessionId } | null> { - if (input.recoverableRunId !== undefined) { - const runScope = - (await getAppDatabase(database) - .select({ sessionId: sessionRunsTable.sessionId }) - .from(sessionRunsTable) - .where(eq(sessionRunsTable.id, input.recoverableRunId)) - .limit(1) - .get()) ?? null; - - if (runScope?.sessionId !== input.session.id) { - throw new Error("Bound Agent idempotency Run is missing or belongs to another Session."); - } - - const run = await getSessionRunSummary(database, input.recoverableRunId); - - if (run === null) { - throw new Error("Bound Agent idempotency Run summary is missing."); - } - - return { runId: run.id, sessionId: input.session.id }; - } - - if (input.clientRequestId !== null) { - const event = - (await getAppDatabase(database) - .select({ runId: sessionEventsTable.runId }) - .from(sessionEventsTable) - .where( - and( - eq(sessionEventsTable.sessionId, input.session.id), - eq(sessionEventsTable.sourceEventId, input.clientRequestId), - ), - ) - .limit(1) - .get()) ?? null; - - if (event?.runId) { - const run = await getSessionRunSummary(database, event.runId); - - if (run !== null) { - return { runId: run.id, sessionId: input.session.id }; - } - } - - // A reserved idempotency key owns a dedicated Session. If the Worker is - // interrupted after the Run insert but before the event receipt or - // reservation binding is durable, the first Run in that Session remains - // the canonical execution even after it becomes terminal. - const firstRun = - (await getAppDatabase(database) - .select({ id: sessionRunsTable.id }) - .from(sessionRunsTable) - .where(eq(sessionRunsTable.sessionId, input.session.id)) - .orderBy(asc(sessionRunsTable.id)) - .limit(1) - .get()) ?? null; - - if (firstRun !== null) { - const run = await getSessionRunSummary(database, firstRun.id); - - if (run === null) { - throw new Error("Bound Agent idempotency Run summary is missing."); - } - - return { runId: run.id, sessionId: input.session.id }; - } - } - - const run = - input.session.lastRun ?? (await getActiveSessionRunSummary(database, input.session.id)); - - return run === null ? null : { runId: run.id, sessionId: input.session.id }; -} - -/** - * Create the session and queue the run as the App owner. On failure before the - * run is queued, the half-created session is cleaned up (mirrors the PAT thread - * path). Once queued, the run is left in place for the wait + extraction. - */ -async function startBoundAgentRun(input: { - agentId: AgentId; - appId: AppId; - bindings: ApiBindings; - capability: AppAgentCapabilityClaims; - clientRequestId: string | null; - executionContext: Pick | null; - ownerViewer: AuthenticatedViewer; - prompt: string; - recoverableSessionId?: SessionId; - recoverableRunId?: SessionRunId; - requestUrl: string; -}): Promise<{ runId: SessionRunId; sessionId: SessionId }> { - let createdSessionId: SessionId | null = null; - - try { - const session = - input.recoverableSessionId === undefined - ? await createAgentSession({ - bindings: input.bindings, - executionContext: input.executionContext, - input: { - agentId: input.agentId, - appId: input.appId, - type: "ui", - }, - options: { - accessViewer: input.ownerViewer, - metadata: null, - }, - viewer: input.ownerViewer, - }) - : await ensureRecoverableBoundSession({ - agentId: input.agentId, - appId: input.appId, - bindings: input.bindings, - executionContext: input.executionContext, - ownerViewer: input.ownerViewer, - sessionId: input.recoverableSessionId, - }); - createdSessionId = session.id; - - const existingRun = await findRecoverableBoundRun(input.bindings.DB, { - clientRequestId: input.clientRequestId, - ...(input.recoverableRunId === undefined ? {} : { recoverableRunId: input.recoverableRunId }), - session, - }); - - if (existingRun !== null) { - return existingRun; - } - - try { - const queued = await queueSessionRun({ - bindings: input.bindings, - executionContext: input.executionContext, - input: { - accessViewer: input.ownerViewer, - attachmentIds: [], - ...createDeploymentCapabilityRunAdmission(input.capability), - clientRequestId: input.clientRequestId, - prompt: input.prompt, - session: { - agent_id: session.agentId, - deployment_version_id: session.deploymentVersionId, - deployment_version_number: session.deploymentVersionNumber, - id: session.id, - model: session.model, - app_id: session.appId, - provider: session.provider, - runtime_id: session.runtimeId, - }, - }, - requestUrl: input.requestUrl, - viewer: input.ownerViewer, - }); - - return { runId: queued.run.id, sessionId: session.id }; - } catch (error) { - const recoveredSession = await loadRecoverableBoundSession({ - agentId: input.agentId, - appId: input.appId, - database: input.bindings.DB, - ownerViewer: input.ownerViewer, - sessionId: session.id, - }); - const recoveredRun = - recoveredSession === null - ? null - : await findRecoverableBoundRun(input.bindings.DB, { - clientRequestId: input.clientRequestId, - ...(input.recoverableRunId === undefined - ? {} - : { recoverableRunId: input.recoverableRunId }), - session: recoveredSession, - }); - - if (recoveredRun !== null) { - return recoveredRun; - } - - throw error; - } - } catch (error) { - if (createdSessionId !== null && input.recoverableSessionId === undefined) { - await cleanupFailedThreadCreation({ - bindings: input.bindings, - fileIds: [], - sessionId: createdSessionId, - }).catch((cleanupError: unknown) => { - logError("public-api.bound_agent_call.cleanup_failed", { - ...createErrorLogContext(cleanupError), - sessionId: createdSessionId, - }); - }); - } - - throw error; - } -} - -export async function createBoundAgentThreadAndWait( - request: CreateBoundAgentThreadAndWaitRequest, -): Promise { - const { agent, claims, ownerViewer } = await admitDeploymentCapability( - request.bindings, - request.token, - Date.now(), - ); - - await enforcePublicApiRateLimit(request.bindings.DB, deploymentCapabilityRateLimitKey(claims)); - - const idempotency = - request.idempotencyKey === null || request.idempotencyKey === undefined - ? null - : await beginBoundAgentCallIdempotency(request.bindings.DB, { - bodyHash: await hashBoundAgentCallIdempotencyBody(request.input.message), - idempotencyKey: request.idempotencyKey, - subjectHash: await hashBoundAgentCallIdempotencySubject(claims), - }); - - let startedRun: { runId: SessionRunId; sessionId: SessionId }; - - try { - startedRun = await startBoundAgentRun({ - agentId: agent.id, - appId: agent.appId, - bindings: request.bindings, - capability: claims, - clientRequestId: idempotency?.reservationId ?? null, - executionContext: request.executionContext, - ownerViewer, - prompt: request.input.message, - ...(idempotency === null ? {} : { recoverableSessionId: idempotency.sessionId }), - ...(idempotency?.runId === null || idempotency?.runId === undefined - ? {} - : { recoverableRunId: idempotency.runId }), - requestUrl: request.requestUrl, - }); - } catch (error) { - if (!(error instanceof SessionRunCreationGuardRejectedError)) { - throw error; - } - - // Surface the current revocation reason when the Run insert lost the race. - await ensureDeploymentCapabilityAuthorized(request.bindings.DB, claims); - throw error; - } - - const { runId, sessionId } = startedRun; - - if (idempotency !== null) { - await bindBoundAgentCallIdempotencyRun(request.bindings.DB, { - reservationId: idempotency.reservationId, - runId, - sessionId, - }); - } - - const terminalRun = await waitForTerminalRun( - { - delay, - now: () => Date.now(), - readRun: () => getSessionRunSummary(request.bindings.DB, runId), - }, - { - pollIntervalMs: BOUND_AGENT_WAIT_POLL_INTERVAL_MS, - timeoutMs: BOUND_AGENT_WAIT_TIMEOUT_MS, - }, - ); - - const finalOutput = - terminalRun.status === "completed" - ? await readPublicThreadRunFinalOutput({ - database: request.bindings.DB, - runId, - sessionId, - }) - : null; - - const { reply } = selectBoundAgentReply({ finalOutput, run: terminalRun }); - - return { reply, runId }; -} diff --git a/apps/api/src/modules/public-api/app-agent-bound-call.ts b/apps/api/src/modules/public-api/app-agent-bound-call.ts deleted file mode 100644 index 5e38c7ad..00000000 --- a/apps/api/src/modules/public-api/app-agent-bound-call.ts +++ /dev/null @@ -1,207 +0,0 @@ -/** - * Pure / stack-free pieces of the bound-agent ask flow. Everything here is unit - * testable without the Worker runtime, the DB, or the session runtime: capability - * verification, the still-published guard, request-body parsing, the bounded - * server-side wait for a terminal run, and final-output extraction. The - * orchestration that wires these to the DB + session runtime lives in - * `app-agent-bound-ask.service.ts`. - */ - -import { PUBLIC_THREAD_INPUT_TEXT_MAX_LENGTH } from "@mosoo/contracts/public-api"; -import type { PublicThreadFinalOutput } from "@mosoo/contracts/public-api"; -import type { RunError, SessionRunStatus } from "@mosoo/contracts/session-run"; - -import type { AgentRow } from "../agents/application/agent-types"; -import { - boundAgentCallTimeout, - boundAgentFinalOutputMissing, - boundAgentNeedsInput, - boundAgentRunFailed, -} from "./app-agent-bound-errors"; -import { inspectAppAgentCapabilityToken } from "./app-agent-capability"; -import type { AppAgentCapabilityClaims } from "./app-agent-capability"; -import type { AppAgentCapabilityTokenVerification } from "./app-agent-capability"; -import { - publicAgentNotExposed, - publicInvalidRequest, - publicUnauthenticated, -} from "./public-api-errors"; - -export interface BoundAgentCallInput { - message: string; -} - -export type BoundAgentServabilityFailure = "agent_mismatched" | "agent_unpublished"; - -function readBoundAgentMessage(value: unknown): string { - if (typeof value !== "object" || value === null || Array.isArray(value)) { - throw publicInvalidRequest("Request body must be a JSON object."); - } - - const record = value as Record; - // Prefer a non-empty `message`; otherwise fall back to `input` (so an empty - // `message` alongside a real `input` is not rejected). - const messageField = record["message"]; - const raw = - typeof messageField === "string" && messageField.trim().length > 0 - ? messageField - : record["input"]; - - if (typeof raw !== "string") { - throw publicInvalidRequest("A non-empty `message` string is required."); - } - - const message = raw.trim(); - - if (message.length === 0) { - throw publicInvalidRequest("A non-empty `message` string is required."); - } - - if (message.length > PUBLIC_THREAD_INPUT_TEXT_MAX_LENGTH) { - throw publicInvalidRequest( - `\`message\` must be at most ${PUBLIC_THREAD_INPUT_TEXT_MAX_LENGTH} characters.`, - ); - } - - return message; -} - -export function parseBoundAgentCallBody(body: unknown): BoundAgentCallInput { - return { message: readBoundAgentMessage(body) }; -} - -/** - * Verify the capability token carried in the URL. Rejects (401) when the token - * is malformed, signed with a different secret, or expired. - */ -export async function verifyBoundAgentCapability( - secret: string, - token: string, - nowMs: number, -): Promise { - const verification = await inspectBoundAgentCapability(secret, token, nowMs); - - if (verification.status !== "valid") { - throw publicUnauthenticated("The capability URL is invalid or has expired."); - } - - return verification.claims; -} - -export async function inspectBoundAgentCapability( - secret: string, - token: string, - nowMs: number, -): Promise { - return inspectAppAgentCapabilityToken(secret, token, nowMs); -} - -/** - * Defense in depth against a revoked binding: re-check the Agent is still - * published (the same criterion the deploy-time resolver used) and that it - * belongs to the App the capability was minted for. - */ -export function getBoundAgentServabilityFailure( - agent: AgentRow, - claims: AppAgentCapabilityClaims, -): BoundAgentServabilityFailure | null { - if (agent.appId !== claims.appId || agent.name !== claims.binding.name) { - return "agent_mismatched"; - } - - if (agent.status !== "published" || agent.liveDeploymentVersionId === null) { - return "agent_unpublished"; - } - - return null; -} - -export function ensureBoundAgentServable(agent: AgentRow, claims: AppAgentCapabilityClaims): void { - if (getBoundAgentServabilityFailure(agent, claims) !== null) { - throw publicAgentNotExposed("This Agent is no longer published for bound calls."); - } -} - -export const BOUND_AGENT_TERMINAL_RUN_STATUSES = [ - "completed", - "failed", - "cancelled", - "expired", -] as const; - -export type BoundAgentTerminalRunStatus = (typeof BOUND_AGENT_TERMINAL_RUN_STATUSES)[number]; - -export function isTerminalRunStatus( - status: SessionRunStatus, -): status is BoundAgentTerminalRunStatus { - return (BOUND_AGENT_TERMINAL_RUN_STATUSES as readonly SessionRunStatus[]).includes(status); -} - -// A run parked waiting for interactive input never reaches a terminal state on -// its own; the single-call bound ask cannot answer it, so we stop waiting and -// surface a clear error instead of letting it run out the clock. -const BOUND_AGENT_BLOCKED_RUN_STATUSES = ["waiting_input"] as const; - -export function isBlockedRunStatus(status: SessionRunStatus): boolean { - return (BOUND_AGENT_BLOCKED_RUN_STATUSES as readonly SessionRunStatus[]).includes(status); -} - -export interface BoundAgentRunWaitDeps { - delay: (ms: number) => Promise; - now: () => number; - readRun: () => Promise; -} - -export interface BoundAgentRunWaitOptions { - pollIntervalMs: number; - timeoutMs: number; -} - -/** - * Poll `readRun` until the run reaches a terminal state, then return that run. - * Throws `boundAgentCallTimeout()` once `timeoutMs` elapses. Dependencies are - * injected so the loop is deterministic under test. - */ -export async function waitForTerminalRun( - deps: BoundAgentRunWaitDeps, - options: BoundAgentRunWaitOptions, -): Promise { - const startedAt = deps.now(); - - for (;;) { - const run = await deps.readRun(); - - if (run !== null && isTerminalRunStatus(run.status)) { - return run; - } - - if (run !== null && isBlockedRunStatus(run.status)) { - throw boundAgentNeedsInput(); - } - - if (deps.now() - startedAt >= options.timeoutMs) { - throw boundAgentCallTimeout(); - } - - await deps.delay(options.pollIntervalMs); - } -} - -/** - * Resolve the reply for a terminal run from its canonical final assistant - * message, otherwise surface a typed failure. - */ -export function selectBoundAgentReply(input: { - finalOutput: PublicThreadFinalOutput | null; - run: { error: RunError | null; status: SessionRunStatus }; -}): { reply: string } { - if (input.run.status !== "completed") { - throw boundAgentRunFailed(input.run.status, input.run.error); - } - - if (input.finalOutput === null) { - throw boundAgentFinalOutputMissing(); - } - - return { reply: input.finalOutput.text }; -} diff --git a/apps/api/src/modules/public-api/app-agent-bound-errors.ts b/apps/api/src/modules/public-api/app-agent-bound-errors.ts deleted file mode 100644 index 6159b262..00000000 --- a/apps/api/src/modules/public-api/app-agent-bound-errors.ts +++ /dev/null @@ -1,114 +0,0 @@ -/** - * Failure model for the bound-agent ask endpoint (`POST /api/v1/bound/:token`). - * - * The bound endpoint authorizes via a self-authorizing capability URL (no PAT), - * so it does not flow through the PAT `runPublicApi*` route helpers. It renders - * its own error responses here. Cases that already have a public-API meaning - * (invalid capability -> 401, Agent un-published -> 409, bad request -> 400) are - * raised with the existing `PublicApiError` helpers and rendered via - * `toPublicApiError`. Failures unique to a one-call blocking ask — the - * run never reaching a terminal state in time, and a terminal-but-not-completed - * run, and a completed run whose canonical reply is missing — get the - * dedicated codes below. - */ - -import type { RunError, SessionRunStatus } from "@mosoo/contracts/session-run"; - -import { createErrorLogContext, logError } from "../../platform/cloudflare/logger"; -import { publicReadinessBlocked, toPublicApiError } from "./public-api-errors"; - -/** Returned when the bound Agent run does not reach a terminal state in time. */ -export const DEPLOYMENT_AGENT_CALL_TIMEOUT_ERROR_CODE = "deployment_agent_call_timeout"; -/** Returned when the bound Agent run finished without a successful reply. */ -export const DEPLOYMENT_AGENT_RUN_FAILED_ERROR_CODE = "deployment_agent_run_failed"; -/** Returned when the bound Agent pauses for interactive input the single-call ask cannot provide. */ -export const DEPLOYMENT_AGENT_NEEDS_INPUT_ERROR_CODE = "deployment_agent_needs_input"; -/** Returned when a completed bound Agent run has no canonical final assistant message. */ -export const DEPLOYMENT_AGENT_FINAL_OUTPUT_MISSING_ERROR_CODE = - "deployment_agent_final_output_missing"; - -export type BoundAgentCallErrorCode = - | typeof DEPLOYMENT_AGENT_CALL_TIMEOUT_ERROR_CODE - | typeof DEPLOYMENT_AGENT_FINAL_OUTPUT_MISSING_ERROR_CODE - | typeof DEPLOYMENT_AGENT_RUN_FAILED_ERROR_CODE - | typeof DEPLOYMENT_AGENT_NEEDS_INPUT_ERROR_CODE; - -export class BoundAgentCallError extends Error { - readonly code: BoundAgentCallErrorCode; - readonly status: number; - - constructor(input: { code: BoundAgentCallErrorCode; message: string; status: number }) { - super(input.message); - this.name = "BoundAgentCallError"; - this.code = input.code; - this.status = input.status; - } -} - -export function boundAgentCallTimeout(): BoundAgentCallError { - return new BoundAgentCallError({ - code: DEPLOYMENT_AGENT_CALL_TIMEOUT_ERROR_CODE, - message: "The bound Agent did not return a final reply before the request timed out.", - status: 504, - }); -} - -export function boundAgentNeedsInput(): BoundAgentCallError { - return new BoundAgentCallError({ - code: DEPLOYMENT_AGENT_NEEDS_INPUT_ERROR_CODE, - message: - "The bound Agent paused for interactive input, which a single-call bound ask cannot provide.", - status: 422, - }); -} - -export function boundAgentFinalOutputMissing(): BoundAgentCallError { - return new BoundAgentCallError({ - code: DEPLOYMENT_AGENT_FINAL_OUTPUT_MISSING_ERROR_CODE, - message: "The bound Agent run completed without a canonical final reply.", - status: 503, - }); -} - -export function boundAgentRunFailed( - status: SessionRunStatus, - error: RunError | null, -): BoundAgentCallError { - return new BoundAgentCallError({ - code: DEPLOYMENT_AGENT_RUN_FAILED_ERROR_CODE, - message: error?.message ?? `The bound Agent run ended without a reply (${status}).`, - status: 502, - }); -} - -interface BoundAgentCallErrorResponse { - body: { error: { code: string; message: string } }; - status: number; -} - -function errorResponse(code: string, message: string, status: number): BoundAgentCallErrorResponse { - return { body: { error: { code, message } }, status }; -} - -export function renderBoundAgentCallError(error: unknown): BoundAgentCallErrorResponse { - if (error instanceof BoundAgentCallError) { - return errorResponse(error.code, error.message, error.status); - } - - const publicError = toPublicApiError(error); - if (publicError) { - return errorResponse(publicError.code, publicError.message, publicError.status); - } - - if (error instanceof Error && error.message.startsWith("Agent is not ready to run:")) { - const readiness = publicReadinessBlocked(error.message); - return errorResponse(readiness.code, readiness.message, readiness.status); - } - - if (error instanceof SyntaxError) { - return errorResponse("invalid_json", "Request body must be valid JSON.", 400); - } - - logError("public-api.bound_agent_call.failed", createErrorLogContext(error)); - return errorResponse("internal_error", "Bound Agent call failed.", 500); -} diff --git a/apps/api/src/modules/public-api/app-agent-bound-idempotency.service.ts b/apps/api/src/modules/public-api/app-agent-bound-idempotency.service.ts deleted file mode 100644 index 3d27ae7e..00000000 --- a/apps/api/src/modules/public-api/app-agent-bound-idempotency.service.ts +++ /dev/null @@ -1,172 +0,0 @@ -import { boundAgentCallIdempotencyKeysTable } from "@mosoo/db"; -import { createPlatformId } from "@mosoo/id"; -import type { PlatformId, SessionId, SessionRunId } from "@mosoo/id"; -import { and, eq, isNull, or } from "drizzle-orm"; - -import { getAppDatabase } from "../../platform/db/drizzle"; -import { currentTimestampMs } from "../../time"; -import type { AppAgentCapabilityClaims } from "./app-agent-capability"; -import { publicIdempotencyConflict } from "./public-api-errors"; -import { hashPublicApiIdempotencyBody } from "./public-api-idempotency.service"; - -const IDEMPOTENCY_RETRY_AFTER_SECONDS = 2; - -interface BoundAgentCallIdempotencyRow { - bodyHash: string; - id: PlatformId; - runId: SessionRunId | null; - sessionId: SessionId; -} - -export interface BoundAgentCallIdempotencyInput { - bodyHash: string; - idempotencyKey: string; - subjectHash: string; -} - -export interface BoundAgentCallIdempotencyReservation { - reservationId: PlatformId; - runId: SessionRunId | null; - sessionId: SessionId; - status: "existing" | "reserved"; -} - -export async function hashBoundAgentCallIdempotencyBody(message: string): Promise { - const bodyHash = await hashPublicApiIdempotencyBody({ message }); - - if (bodyHash === null) { - throw new Error("Bound Agent idempotency body cannot be empty."); - } - - return bodyHash; -} - -export async function hashBoundAgentCallIdempotencySubject( - claims: AppAgentCapabilityClaims, -): Promise { - const subjectHash = await hashPublicApiIdempotencyBody({ - agentId: claims.agentId, - appId: claims.appId, - binding: { - env: claims.binding.env, - expose: claims.binding.expose, - name: claims.binding.name, - }, - deploymentId: claims.deploymentId, - deploymentRunId: claims.deploymentRunId, - }); - - if (subjectHash === null) { - throw new Error("Bound Agent idempotency subject cannot be empty."); - } - - return subjectHash; -} - -async function readReservation( - database: D1Database, - input: Pick, -): Promise { - return ( - (await getAppDatabase(database) - .select({ - bodyHash: boundAgentCallIdempotencyKeysTable.bodyHash, - id: boundAgentCallIdempotencyKeysTable.id, - runId: boundAgentCallIdempotencyKeysTable.runId, - sessionId: boundAgentCallIdempotencyKeysTable.sessionId, - }) - .from(boundAgentCallIdempotencyKeysTable) - .where( - and( - eq(boundAgentCallIdempotencyKeysTable.subjectHash, input.subjectHash), - eq(boundAgentCallIdempotencyKeysTable.idempotencyKey, input.idempotencyKey), - ), - ) - .limit(1) - .get()) ?? null - ); -} - -export async function beginBoundAgentCallIdempotency( - database: D1Database, - input: BoundAgentCallIdempotencyInput, -): Promise { - const reservationId = createPlatformId(); - const sessionId = createPlatformId(); - const timestampMs = currentTimestampMs(); - - await getAppDatabase(database) - .insert(boundAgentCallIdempotencyKeysTable) - .values({ - bodyHash: input.bodyHash, - createdAt: timestampMs, - id: reservationId, - idempotencyKey: input.idempotencyKey, - sessionId, - subjectHash: input.subjectHash, - updatedAt: timestampMs, - }) - .onConflictDoNothing() - .run(); - - const current = await readReservation(database, input); - - if (current === null) { - throw publicIdempotencyConflict( - "Idempotency-Key reservation could not be confirmed.", - IDEMPOTENCY_RETRY_AFTER_SECONDS, - ); - } - - if (current.bodyHash !== input.bodyHash) { - throw publicIdempotencyConflict( - "Idempotency-Key was already used for a different request.", - IDEMPOTENCY_RETRY_AFTER_SECONDS, - ); - } - - return { - reservationId: current.id, - runId: current.runId, - sessionId: current.sessionId, - status: current.id === reservationId ? "reserved" : "existing", - }; -} - -export async function bindBoundAgentCallIdempotencyRun( - database: D1Database, - input: { - reservationId: PlatformId; - runId: SessionRunId; - sessionId: SessionId; - }, -): Promise { - await getAppDatabase(database) - .update(boundAgentCallIdempotencyKeysTable) - .set({ - runId: input.runId, - updatedAt: currentTimestampMs(), - }) - .where( - and( - eq(boundAgentCallIdempotencyKeysTable.id, input.reservationId), - eq(boundAgentCallIdempotencyKeysTable.sessionId, input.sessionId), - or( - isNull(boundAgentCallIdempotencyKeysTable.runId), - eq(boundAgentCallIdempotencyKeysTable.runId, input.runId), - ), - ), - ) - .run(); - - const current = await getAppDatabase(database) - .select({ runId: boundAgentCallIdempotencyKeysTable.runId }) - .from(boundAgentCallIdempotencyKeysTable) - .where(eq(boundAgentCallIdempotencyKeysTable.id, input.reservationId)) - .limit(1) - .get(); - - if (current?.runId !== input.runId) { - throw new Error("Bound Agent idempotency Run binding could not be confirmed."); - } -} diff --git a/apps/api/src/modules/public-api/app-agent-capability.ts b/apps/api/src/modules/public-api/app-agent-capability.ts deleted file mode 100644 index 35d93d99..00000000 --- a/apps/api/src/modules/public-api/app-agent-capability.ts +++ /dev/null @@ -1,183 +0,0 @@ -/** - * Self-authorizing capability token for a deployed App's bound Agent. - * - * A deployed app reads one injected env var per binding whose value is a URL - * carrying this token. The token encodes the App, Agent, deployment revision, - * and binding that authorized it, and is signed with an HMAC secret, so the - * bound app needs no API key — the URL itself is the grant (PM decision #1, - * docs/prd/app-deployment.md "Agent Binding Wedge"). - * - * The token remains stateless, but the ask endpoint re-checks its deployment - * authority against D1 before starting a Run. Uses Web Crypto so it runs on - * Workers. - */ - -import type { AgentId, AppDeploymentId, AppDeploymentRunId, AppId } from "@mosoo/id"; -import { isPlatformId } from "@mosoo/id"; - -export type AppAgentCapabilityExpose = "public_thread"; - -export interface AppAgentCapabilityBinding { - env: string; - expose: AppAgentCapabilityExpose; - name: string; -} - -export interface AppAgentCapabilityClaims { - agentId: AgentId; - appId: AppId; - binding: AppAgentCapabilityBinding; - deploymentId: AppDeploymentId; - deploymentRunId: AppDeploymentRunId; - /** Absolute expiry, epoch milliseconds. */ - exp: number; -} - -export type AppAgentCapabilityTokenVerification = - | { claims: AppAgentCapabilityClaims; status: "expired" | "valid" } - | { status: "invalid" }; - -const HMAC_PARAMS: HmacKeyGenParams = { hash: "SHA-256", name: "HMAC" }; - -/** Path the deployed app's injected URL points at (the capability ask endpoint). */ -export const APP_AGENT_BOUND_PATH_PREFIX = "/api/v1/bound"; - -/** Strip trailing slashes without a backtracking regex (avoids ReDoS on library input). */ -function stripTrailingSlashes(value: string): string { - let end = value.length; - while (end > 0 && value.charCodeAt(end - 1) === 47 /* "/" */) { - end -= 1; - } - return value.slice(0, end); -} - -/** Build the self-authorizing URL injected as a bound agent's env var. */ -export function boundAgentUrl(apiOrigin: string, token: string): string { - return `${stripTrailingSlashes(apiOrigin)}${APP_AGENT_BOUND_PATH_PREFIX}/${token}`; -} - -function bytesToBase64Url(bytes: Uint8Array): string { - let binary = ""; - for (const byte of bytes) { - binary += String.fromCharCode(byte); - } - return btoa(binary).replaceAll("+", "-").replaceAll("/", "_").replaceAll("=", ""); -} - -function base64UrlToBytes(value: string): Uint8Array { - const normalized = value.replaceAll("-", "+").replaceAll("_", "/"); - const binary = atob(normalized); - const bytes = new Uint8Array(binary.length); - for (let index = 0; index < binary.length; index += 1) { - bytes[index] = binary.charCodeAt(index); - } - return bytes; -} - -async function importSigningKey(secret: string): Promise { - return crypto.subtle.importKey("raw", new TextEncoder().encode(secret), HMAC_PARAMS, false, [ - "sign", - "verify", - ]); -} - -function isCapabilityClaims(value: unknown): value is AppAgentCapabilityClaims { - if (typeof value !== "object" || value === null) { - return false; - } - const record = value as Record; - const binding = record["binding"]; - - if (typeof binding !== "object" || binding === null || Array.isArray(binding)) { - return false; - } - - const bindingRecord = binding as Record; - return ( - isPlatformId(record["agentId"]) && - isPlatformId(record["appId"]) && - isPlatformId(record["deploymentId"]) && - isPlatformId(record["deploymentRunId"]) && - typeof record["exp"] === "number" && - typeof bindingRecord["env"] === "string" && - bindingRecord["env"].length > 0 && - bindingRecord["expose"] === "public_thread" && - typeof bindingRecord["name"] === "string" && - bindingRecord["name"].length > 0 - ); -} - -/** Mint a signed, URL-safe capability token for a bound agent. */ -export async function mintAppAgentCapabilityToken( - secret: string, - claims: AppAgentCapabilityClaims, -): Promise { - const payload = bytesToBase64Url(new TextEncoder().encode(JSON.stringify(claims))); - const key = await importSigningKey(secret); - const signature = await crypto.subtle.sign( - HMAC_PARAMS.name, - key, - new TextEncoder().encode(payload), - ); - return `${payload}.${bytesToBase64Url(new Uint8Array(signature))}`; -} - -/** - * Verify a capability token and return its claims, or null when the signature is - * invalid, the token is malformed, or it has expired at `nowMs`. - */ -export async function verifyAppAgentCapabilityToken( - secret: string, - token: string, - nowMs: number, -): Promise { - const verification = await inspectAppAgentCapabilityToken(secret, token, nowMs); - - return verification.status === "valid" ? verification.claims : null; -} - -/** - * Verify a token while preserving the single safe diagnostic state: a - * correctly signed capability whose authority has expired. Callers must keep - * invalid capabilities indistinguishable to external clients. - */ -export async function inspectAppAgentCapabilityToken( - secret: string, - token: string, - nowMs: number, -): Promise { - const separator = token.indexOf("."); - if (separator <= 0 || separator === token.length - 1) { - return { status: "invalid" }; - } - const payload = token.slice(0, separator); - const signaturePart = token.slice(separator + 1); - - let signatureValid: boolean; - try { - const key = await importSigningKey(secret); - signatureValid = await crypto.subtle.verify( - HMAC_PARAMS.name, - key, - base64UrlToBytes(signaturePart), - new TextEncoder().encode(payload), - ); - } catch { - return { status: "invalid" }; - } - if (!signatureValid) { - return { status: "invalid" }; - } - - let parsed: unknown; - try { - parsed = JSON.parse(new TextDecoder().decode(base64UrlToBytes(payload))); - } catch { - return { status: "invalid" }; - } - if (!isCapabilityClaims(parsed)) { - return { status: "invalid" }; - } - - return { claims: parsed, status: parsed.exp <= nowMs ? "expired" : "valid" }; -} diff --git a/apps/api/src/modules/public-api/bound-capability-run-provenance.service.ts b/apps/api/src/modules/public-api/bound-capability-run-provenance.service.ts deleted file mode 100644 index e3e6773b..00000000 --- a/apps/api/src/modules/public-api/bound-capability-run-provenance.service.ts +++ /dev/null @@ -1,76 +0,0 @@ -import { sessionRunsTable } from "@mosoo/db"; -import type { AgentId, AppDeploymentId, AppDeploymentRunId, AppId, SessionRunId } from "@mosoo/id"; -import { and, eq } from "drizzle-orm"; - -import { getAppDatabase } from "../../platform/db/drizzle"; -import { ensureAppOwnership } from "../apps/application/app.service"; -import type { AuthenticatedViewer } from "../auth/application/viewer-auth.service"; - -export interface BoundCapabilityRunProvenanceRecord { - agentId: AgentId; - appId: AppId; - bindingEnv: string; - bindingName: string; - deploymentId: AppDeploymentId; - deploymentRunId: AppDeploymentRunId; - runId: SessionRunId; -} - -/** - * Returns the immutable, non-secret delegation facts for a Run. App ownership - * is intentionally required because deployment identifiers and binding names - * are operational audit data, not part of the public Thread response. - */ -export async function getBoundCapabilityRunProvenance( - database: D1Database, - viewer: AuthenticatedViewer, - input: { appId: AppId; runId: SessionRunId }, -): Promise { - await ensureAppOwnership(database, viewer.id, input.appId); - - const row = - (await getAppDatabase(database) - .select({ - agentId: sessionRunsTable.boundCapabilityAgentId, - appId: sessionRunsTable.boundCapabilityAppId, - bindingEnv: sessionRunsTable.boundCapabilityBindingEnv, - bindingName: sessionRunsTable.boundCapabilityBindingName, - deploymentId: sessionRunsTable.boundCapabilityDeploymentId, - deploymentRunId: sessionRunsTable.boundCapabilityDeploymentRunId, - runId: sessionRunsTable.id, - }) - .from(sessionRunsTable) - .where( - and( - eq(sessionRunsTable.id, input.runId), - eq(sessionRunsTable.boundCapabilityAppId, input.appId), - ), - ) - .limit(1) - .get()) ?? null; - - if (row === null) { - return null; - } - - if ( - row.agentId === null || - row.appId === null || - row.bindingEnv === null || - row.bindingName === null || - row.deploymentId === null || - row.deploymentRunId === null - ) { - throw new Error("Bound capability Run provenance must be complete when present."); - } - - return { - agentId: row.agentId, - appId: row.appId, - bindingEnv: row.bindingEnv, - bindingName: row.bindingName, - deploymentId: row.deploymentId, - deploymentRunId: row.deploymentRunId, - runId: row.runId, - }; -} diff --git a/apps/api/src/modules/public-api/deployment-capability-caller.service.ts b/apps/api/src/modules/public-api/deployment-capability-caller.service.ts deleted file mode 100644 index 9d69c040..00000000 --- a/apps/api/src/modules/public-api/deployment-capability-caller.service.ts +++ /dev/null @@ -1,167 +0,0 @@ -/** - * Admission for the deployment-scoped runtime identity a deployed App receives - * through its injected bound Agent capability URL. - * - * Every bound request — the blocking ask and the Public Thread / file routes - * mounted under the same URL — passes through `admitDeploymentCapability` - * first: verify the signed token, re-check that the Agent is still published - * for the App, re-check that the Deployment revision still carries the binding, - * and resolve the App owner the capability acts on behalf of. The result is a - * `PublicApiCaller` whose thread and file admission is narrowed to the App, - * Agent binding, and Deployment named by the claims; the owner's account-wide - * Access Token never reaches deployed code. - */ - -import { logInfo } from "../../platform/cloudflare/logger"; -import type { ApiBindings } from "../../platform/cloudflare/worker-types"; -import { getAgentRow } from "../agents/application/agent-repository"; -import type { AgentRow } from "../agents/application/agent-types"; -import { - createDeploymentAgentCapabilityRunCreationGuard, - getDeploymentAgentCapabilityAuthority, -} from "../apps/application/app-deployment-capability-authority.service"; -import type { DeploymentAgentCapabilityAuthorityRejection } from "../apps/application/app-deployment-capability-authority.service"; -import { getAppRow } from "../apps/application/app.service"; -import { - getAccountViewer, - toDeploymentCapabilityCredentialSubjectId, -} from "../auth/application/public-api-caller.service"; -import type { DeploymentCapabilityPublicApiCaller } from "../auth/application/public-api-caller.service"; -import type { AuthenticatedViewer } from "../auth/application/viewer-auth.service"; -import type { BoundCapabilityRunAdmission } from "../runtime/domain/bound-capability-run-provenance"; -import { - getBoundAgentServabilityFailure, - inspectBoundAgentCapability, -} from "./app-agent-bound-call"; -import type { BoundAgentServabilityFailure } from "./app-agent-bound-call"; -import type { AppAgentCapabilityClaims } from "./app-agent-capability"; -import { publicAgentNotExposed, publicNotFound, publicUnauthenticated } from "./public-api-errors"; - -export interface DeploymentCapabilityAdmission { - agent: AgentRow; - claims: AppAgentCapabilityClaims; - ownerViewer: AuthenticatedViewer; -} - -export type DeploymentCapabilityRejectionReason = - | BoundAgentServabilityFailure - | DeploymentAgentCapabilityAuthorityRejection - | "expired"; - -export const DEPLOYMENT_CAPABILITY_INVALID_MESSAGE = - "The capability URL is invalid or has expired."; -export const DEPLOYMENT_CAPABILITY_AGENT_UNPUBLISHED_MESSAGE = - "This Agent is no longer published for bound calls."; -export const DEPLOYMENT_CAPABILITY_REVOKED_MESSAGE = - "This capability is no longer authorized for the active deployment."; - -export function logDeploymentCapabilityRejection( - claims: AppAgentCapabilityClaims, - reason: DeploymentCapabilityRejectionReason, -): void { - logInfo("public-api.bound_agent_capability.rejected", { - agentId: claims.agentId, - appId: claims.appId, - bindingEnv: claims.binding.env, - bindingName: claims.binding.name, - deploymentId: claims.deploymentId, - deploymentRunId: claims.deploymentRunId, - reason, - }); -} - -/** - * Re-run the revocable checks (Agent still published for the App, Deployment - * revision still carries the binding) without re-verifying the signature. - * Used both on entry and after a guarded Run insert is rejected, so the - * rejection reason reflects the current D1 state. - */ -export async function ensureDeploymentCapabilityAuthorized( - database: D1Database, - claims: AppAgentCapabilityClaims, -): Promise { - const agent = await getAgentRow(database, claims.agentId); - const agentFailure = getBoundAgentServabilityFailure(agent, claims); - - if (agentFailure !== null) { - logDeploymentCapabilityRejection(claims, agentFailure); - throw publicAgentNotExposed(DEPLOYMENT_CAPABILITY_AGENT_UNPUBLISHED_MESSAGE); - } - - const authority = await getDeploymentAgentCapabilityAuthority(database, claims); - - if (!authority.authorized) { - logDeploymentCapabilityRejection(claims, authority.reason); - throw publicAgentNotExposed(DEPLOYMENT_CAPABILITY_REVOKED_MESSAGE); - } - - return agent; -} - -export async function admitDeploymentCapability( - bindings: ApiBindings, - token: string, - nowMs: number, -): Promise { - const verification = await inspectBoundAgentCapability( - bindings.RUNTIME_ACTION_TOKEN_SECRET, - token, - nowMs, - ); - - if (verification.status !== "valid") { - if (verification.status === "expired") { - logDeploymentCapabilityRejection(verification.claims, "expired"); - } - - throw publicUnauthenticated(DEPLOYMENT_CAPABILITY_INVALID_MESSAGE); - } - - const claims = verification.claims; - const agent = await ensureDeploymentCapabilityAuthorized(bindings.DB, claims); - const app = await getAppRow(bindings.DB, agent.appId); - const ownerViewer = await getAccountViewer(bindings.DB, app.ownerAccountId); - - if (ownerViewer === null) { - throw publicNotFound("App owner account was not found."); - } - - return { agent, claims, ownerViewer }; -} - -export function toDeploymentCapabilityCaller( - admission: DeploymentCapabilityAdmission, -): DeploymentCapabilityPublicApiCaller { - return { - capability: admission.claims, - credentialSubjectId: toDeploymentCapabilityCredentialSubjectId(admission.claims.deploymentId), - kind: "deployment_capability", - viewer: admission.ownerViewer, - }; -} - -/** - * The capability URL is keyless, long-lived, and internet-facing: without a - * limit a single leaked URL could launch unbounded owner-billed runs. Reuse the - * shared public-API limiter keyed on the capability identity, in a dedicated - * `bound:` bucket namespace so it never collides with Access Token ids. - */ -export function deploymentCapabilityRateLimitKey(claims: AppAgentCapabilityClaims): string { - return `bound:${claims.appId}:${claims.agentId}`; -} - -export function createDeploymentCapabilityRunAdmission( - claims: AppAgentCapabilityClaims, -): BoundCapabilityRunAdmission { - return { - boundCapabilityProvenance: { - agentId: claims.agentId, - appId: claims.appId, - bindingEnv: claims.binding.env, - bindingName: claims.binding.name, - deploymentId: claims.deploymentId, - deploymentRunId: claims.deploymentRunId, - }, - runCreationGuard: createDeploymentAgentCapabilityRunCreationGuard(claims), - }; -} diff --git a/apps/api/src/modules/public-api/public-thread-admission.ts b/apps/api/src/modules/public-api/public-thread-admission.ts index ed2c23b7..af4aa14a 100644 --- a/apps/api/src/modules/public-api/public-thread-admission.ts +++ b/apps/api/src/modules/public-api/public-thread-admission.ts @@ -1,15 +1,11 @@ import type { AccountId, AgentId, AppId, PlatformId } from "@mosoo/id"; -import type { PublicApiCaller } from "../auth/application/public-api-caller.service"; -import { getAccountViewer } from "../auth/application/public-api-caller.service"; +import type { PersonalAccessTokenCaller } from "../auth/application/personal-access-token.service"; +import { getAccountViewer } from "../auth/application/viewer-auth.service"; import type { AuthenticatedViewer } from "../auth/application/viewer-auth.service"; import { admitAgentApiEndpointCaller } from "./agent-api-endpoint-admission.service"; import { publicNotFound } from "./public-api-errors"; -import { isDeploymentCapabilityCreatedBy } from "./public-thread-metadata"; -import type { - PublicApiThreadCreatedByMetadata, - PublicApiThreadMetadata, -} from "./public-thread-metadata"; +import type { PublicApiThreadCreatedByMetadata } from "./public-thread-metadata"; export interface ThreadCreationAdmission { accessViewer: AuthenticatedViewer; @@ -20,7 +16,6 @@ export interface ThreadCreationAdmission { } interface ThreadReadSnapshot { - metadata: PublicApiThreadMetadata; row: { creator_account_id: PlatformId; }; @@ -44,85 +39,43 @@ async function getOwnerViewer( } /** The `created_by` facts a caller stamps on every Thread it creates. */ -export function toPublicApiThreadCreatedBy( - caller: PublicApiCaller, +function toPublicApiThreadCreatedBy( + caller: PersonalAccessTokenCaller, ): PublicApiThreadCreatedByMetadata { - if (caller.kind === "deployment_capability") { - return { - binding_env: caller.capability.binding.env, - binding_name: caller.capability.binding.name, - deployment_id: caller.capability.deploymentId, - deployment_run_id: caller.capability.deploymentRunId, - kind: "deployment_capability", - }; - } - return { token_id: caller.tokenId, token_label: caller.tokenLabel, }; } -/** - * A deployment capability may only address the Agent its binding declared. - * Any other Agent id — even one the App owner controls — is indistinguishable - * from a missing Agent to the deployed App. - */ -function ensureCapabilityAgent(caller: PublicApiCaller, agentId: AgentId): void { - if (caller.kind === "deployment_capability" && caller.capability.agentId !== agentId) { - throw publicNotFound("Agent not found."); - } -} - function canReadThreadFromOwnership( - caller: PublicApiCaller, + caller: AuthenticatedViewer, snapshot: ThreadReadSnapshot, ): boolean { - if (snapshot.row.creator_account_id !== caller.viewer.id) { - return false; - } - - if (caller.kind !== "deployment_capability") { - return true; - } - - const createdBy = snapshot.metadata.created_by; - - return ( - isDeploymentCapabilityCreatedBy(createdBy) && - createdBy.deployment_id === caller.capability.deploymentId && - snapshot.session.agentId === caller.capability.agentId && - snapshot.session.appId === caller.capability.appId - ); + return snapshot.row.creator_account_id === caller.id; } export async function admitPublicThreadReader( database: D1Database, - caller: PublicApiCaller, + caller: AuthenticatedViewer, snapshot: ThreadReadSnapshot, ): Promise { if (!canReadThreadFromOwnership(caller, snapshot)) { throw publicNotFound("Thread not found."); } - await admitAgentApiEndpointCaller(database, caller.viewer, snapshot.session.agentId); + await admitAgentApiEndpointCaller(database, caller, snapshot.session.agentId); } export async function admitPublicThreadCreator( database: D1Database, - caller: PublicApiCaller, + caller: PersonalAccessTokenCaller, input: { agentId: AgentId; }, ): Promise { - ensureCapabilityAgent(caller, input.agentId); - const agent = await admitAgentApiEndpointCaller(database, caller.viewer, input.agentId); - if (caller.kind === "deployment_capability" && agent.appId !== caller.capability.appId) { - throw publicNotFound("Agent not found."); - } - return { accessViewer: await getOwnerViewer(database, agent.ownerId), creatorViewer: caller.viewer, diff --git a/apps/api/src/modules/public-api/public-thread-api-command.service.ts b/apps/api/src/modules/public-api/public-thread-api-command.service.ts index 77d15fc7..daabb669 100644 --- a/apps/api/src/modules/public-api/public-thread-api-command.service.ts +++ b/apps/api/src/modules/public-api/public-thread-api-command.service.ts @@ -4,14 +4,10 @@ import type { PublicThreadApiSendEventsResponse, } from "@mosoo/contracts/public-api"; import type { AgentSessionEventInput } from "@mosoo/contracts/session"; -import { accountsTable } from "@mosoo/db"; -import { parsePlatformId } from "@mosoo/id"; -import type { AccountId, PublicThreadId } from "@mosoo/id"; -import { eq } from "drizzle-orm"; +import type { PublicThreadId } from "@mosoo/id"; import type { ApiBindings } from "../../platform/cloudflare/worker-types"; -import { getAppDatabase } from "../../platform/db/drizzle"; -import type { PublicApiCaller } from "../auth/application/public-api-caller.service"; +import { getAccountViewer } from "../auth/application/viewer-auth.service"; import type { AuthenticatedViewer } from "../auth/application/viewer-auth.service"; import { sendAgentSessionEvents } from "../runtime/application/session-run.service"; import { @@ -19,7 +15,6 @@ import { deleteAgentSession, unarchiveAgentSession, } from "../sessions/application/session-lifecycle-mutation.service"; -import { createDeploymentCapabilityRunAdmission } from "./deployment-capability-caller.service"; import { publicNotFound } from "./public-api-errors"; import { toPublicThreadEventBatch, @@ -30,40 +25,9 @@ import { toBackingSessionId } from "./public-thread-ids"; import { toPublicThreadSummary } from "./public-thread-presenter"; import { admitPublicSessionCaller } from "./public-thread-session-query.service"; -async function getAccountViewer( - database: D1Database, - accountId: AccountId, -): Promise { - const row = - (await getAppDatabase(database) - .select({ - email: accountsTable.email, - email_verified: accountsTable.emailVerified, - id: accountsTable.id, - image_url: accountsTable.image, - name: accountsTable.name, - }) - .from(accountsTable) - .where(eq(accountsTable.id, accountId)) - .limit(1) - .get()) ?? null; - - if (!row) { - throw publicNotFound("Agent owner account was not found."); - } - - return { - email: row.email, - emailVerified: row.email_verified, - id: parsePlatformId(row.id, "Account ID") as AccountId, - imageUrl: row.image_url, - name: row.name, - }; -} - export interface SendPublicThreadSessionEventsRequest { bindings: ApiBindings; - caller: PublicApiCaller; + caller: AuthenticatedViewer; executionContext: Pick | null; input: PublicThreadApiSendEventsRequest; requestUrl: string; @@ -72,19 +36,19 @@ export interface SendPublicThreadSessionEventsRequest { export interface PublicThreadSessionMutationRequest { bindings: ApiBindings; - caller: PublicApiCaller; + caller: AuthenticatedViewer; threadId: PublicThreadId; } export interface UnarchivePublicThreadSessionRequest { - caller: PublicApiCaller; + caller: AuthenticatedViewer; database: D1Database; threadId: PublicThreadId; } async function toAgentSessionEventInput(input: { bindings: ApiBindings; - caller: PublicApiCaller; + caller: AuthenticatedViewer; event: PublicThreadEventInput; threadId: PublicThreadId; }): Promise { @@ -116,6 +80,10 @@ export async function sendPublicThreadSessionEvents( request.threadId, ); const accessViewer = await getAccountViewer(request.bindings.DB, admission.agent.ownerId); + + if (!accessViewer) { + throw publicNotFound("Agent owner account was not found."); + } const events = await Promise.all( request.input.events.map((event) => toAgentSessionEventInput({ @@ -137,14 +105,9 @@ export async function sendPublicThreadSessionEvents( options: { accessViewer, actionAuthorization: "admitted", - // Follow-up Runs started through a deployment capability carry the same - // provenance and D1 revocation fence as the Thread's first Run. - ...(request.caller.kind === "deployment_capability" - ? { boundCapability: createDeploymentCapabilityRunAdmission(request.caller.capability) } - : {}), }, requestUrl: request.requestUrl, - viewer: request.caller.viewer, + viewer: request.caller, }); return toPublicThreadEventBatch({ batch, @@ -169,7 +132,7 @@ export async function archivePublicThreadSession( bindings: request.bindings, appId: admission.session.app_id, sessionId, - viewer: request.caller.viewer, + viewer: request.caller, }); } @@ -187,7 +150,7 @@ export async function unarchivePublicThreadSession( database: request.database, appId: admission.session.app_id, sessionId, - viewer: request.caller.viewer, + viewer: request.caller, }); } @@ -205,6 +168,6 @@ export async function deletePublicThreadSession( bindings: request.bindings, appId: admission.session.app_id, sessionId, - viewer: request.caller.viewer, + viewer: request.caller, }); } diff --git a/apps/api/src/modules/public-api/public-thread-create.ts b/apps/api/src/modules/public-api/public-thread-create.ts index c43024a1..0072c7df 100644 --- a/apps/api/src/modules/public-api/public-thread-create.ts +++ b/apps/api/src/modules/public-api/public-thread-create.ts @@ -6,7 +6,6 @@ import type { ApiBindings } from "../../platform/cloudflare/worker-types"; import type { AuthenticatedViewer } from "../auth/application/viewer-auth.service"; import { fileStore } from "../files/application/file-store"; import { createAgentSession, queueSessionRun } from "../runtime/application/session-run.service"; -import { createDeploymentCapabilityRunAdmission } from "./deployment-capability-caller.service"; import { admitPublicThreadCreator } from "./public-thread-admission"; import type { ThreadCreationAdmission } from "./public-thread-admission"; import { toPublicThreadSessionSummary } from "./public-thread-api-presenter"; @@ -108,19 +107,12 @@ export async function createPublicThread( }); } - // A deployment capability stamps its delegation facts on the Run and - // repeats the Deployment authority condition inside the Run insert, so a - // deletion or revision replacement that commits mid-request cannot create - // an owner-billed Run (docs/prd/app-deployment.md). const queuedRun = await queueSessionRun({ bindings: request.bindings, executionContext: request.executionContext ?? null, input: { accessViewer: admission.accessViewer, attachmentIds: request.input.fileIds, - ...(request.caller.kind === "deployment_capability" - ? createDeploymentCapabilityRunAdmission(request.caller.capability) - : {}), clientRequestId: null, prompt: request.input.inputText, session: { @@ -188,8 +180,8 @@ export async function recoverPublicThreadCreation( }); const snapshot = await findPublicThreadSnapshotByIdempotencyKey(request.bindings.DB, { agentId: request.agentId, - createdBy: admission.createdBy, idempotencyKey: request.idempotencyKey, + tokenId: admission.createdBy.token_id, }); if (!snapshot) { diff --git a/apps/api/src/modules/public-api/public-thread-file-api.service.ts b/apps/api/src/modules/public-api/public-thread-file-api.service.ts index 6866b309..4da348ba 100644 --- a/apps/api/src/modules/public-api/public-thread-file-api.service.ts +++ b/apps/api/src/modules/public-api/public-thread-file-api.service.ts @@ -10,17 +10,17 @@ import { parsePlatformId } from "@mosoo/id"; import type { AgentId, AppId, FileId, PublicThreadId, SessionId } from "@mosoo/id"; import type { ApiBindings } from "../../platform/cloudflare/worker-types"; -import type { PublicApiCaller } from "../auth/application/public-api-caller.service"; +import type { AuthenticatedViewer } from "../auth/application/viewer-auth.service"; import { FileControlError } from "../files/application/file-control-errors"; import { fileStore } from "../files/application/file-store"; import { publishSessionResourceDelete } from "../sessions/application/session-resource-events.service"; -import { admitPublicThreadCreator } from "./public-thread-admission"; +import { admitAgentApiEndpointCaller } from "./agent-api-endpoint-admission.service"; import { toBackingSessionId, toPublicThreadId } from "./public-thread-ids"; import { admitPublicSessionCaller } from "./public-thread-session-query.service"; async function admitPublicThreadFileAccess( bindings: ApiBindings, - caller: PublicApiCaller, + caller: AuthenticatedViewer, threadId: PublicThreadId, ): Promise<{ appId: AppId; sessionId: SessionId }> { const admission = await admitPublicSessionCaller(bindings.DB, caller, threadId); @@ -76,10 +76,10 @@ function toPublicFile(file: FileEntry | FileRecord): PublicFile { async function admitPublicFileRecord( bindings: ApiBindings, - caller: PublicApiCaller, + caller: AuthenticatedViewer, fileId: FileId, ): Promise { - const file = await fileStore.getRecord(bindings, caller.viewer, fileId); + const file = await fileStore.getRecord(bindings, caller, fileId); if (file.scope.kind === "session") { const threadId = requirePublicThreadFile(file); @@ -87,10 +87,7 @@ async function admitPublicFileRecord( return file; } - // App drafts carry no record of which integration uploaded them. An Access - // Token acts for the whole App; a deployment capability only sees a file once - // it is attached to one of its own Threads. - if (file.scope.kind === "app_draft" && caller.kind === "access_token") { + if (file.scope.kind === "app_draft") { return file; } @@ -99,13 +96,13 @@ async function admitPublicFileRecord( export async function listPublicThreadFiles( bindings: ApiBindings, - caller: PublicApiCaller, + caller: AuthenticatedViewer, threadId: PublicThreadId, ): Promise { const { appId, sessionId } = await admitPublicThreadFileAccess(bindings, caller, threadId); return { files: ( - await fileStore.list(bindings, caller.viewer, { + await fileStore.list(bindings, caller, { appId, sessionId, }) @@ -115,7 +112,7 @@ export async function listPublicThreadFiles( export async function createPublicAgentFile( bindings: ApiBindings, - caller: PublicApiCaller, + caller: AuthenticatedViewer, input: { agentId: AgentId; file: File; @@ -129,10 +126,8 @@ export async function createPublicAgentFile( ); } - const admission = await admitPublicThreadCreator(bindings.DB, caller, { - agentId: input.agentId, - }); - const upload = await fileStore.createUpload(bindings, admission.fileViewer, { + const agent = await admitAgentApiEndpointCaller(bindings.DB, caller, input.agentId); + const upload = await fileStore.createUpload(bindings, caller, { file: { contentType: input.file.type || "application/octet-stream", name: input.file.name, @@ -141,18 +136,18 @@ export async function createPublicAgentFile( overwrite: false, purpose: "app_draft", target: { - id: admission.appId, + id: agent.appId, kind: "app_draft", name: input.file.name, }, }); - await fileStore.putContent(bindings, admission.fileViewer, upload.fileId, input.file.stream()); + await fileStore.putContent(bindings, caller, upload.fileId, input.file.stream()); const completed = await fileStore.completeUpload({ bindings, fileId: upload.fileId, input: {}, - viewer: admission.fileViewer, + viewer: caller, }); return { @@ -162,7 +157,7 @@ export async function createPublicAgentFile( export async function retrievePublicFile( bindings: ApiBindings, - caller: PublicApiCaller, + caller: AuthenticatedViewer, fileId: FileId, ): Promise { const file = await admitPublicFileRecord(bindings, caller, fileId); @@ -173,7 +168,7 @@ export async function retrievePublicFile( export async function claimPublicThreadFiles( bindings: ApiBindings, - caller: PublicApiCaller, + caller: AuthenticatedViewer, input: { fileIds: FileId[]; threadId: PublicThreadId; @@ -184,24 +179,19 @@ export async function claimPublicThreadFiles( } const { sessionId } = await admitPublicThreadFileAccess(bindings, caller, input.threadId); - const claimedFiles = await fileStore.claimToSession( - bindings, - caller.viewer, - sessionId, - input.fileIds, - ); + const claimedFiles = await fileStore.claimToSession(bindings, caller, sessionId, input.fileIds); return claimedFiles.map((file) => parsePlatformId(file.id, "File ID")); } export async function deletePublicFile( bindings: ApiBindings, - caller: PublicApiCaller, + caller: AuthenticatedViewer, fileId: FileId, ): Promise { const file = await admitPublicFileRecord(bindings, caller, fileId); - await fileStore.delete(bindings, caller.viewer, fileId); + await fileStore.delete(bindings, caller, fileId); if (file.scope.kind === "session" && file.scope.id !== null) { await publishSessionResourceDelete({ @@ -214,22 +204,17 @@ export async function deletePublicFile( export async function downloadPublicThreadFileContent( bindings: ApiBindings, - caller: PublicApiCaller, + caller: AuthenticatedViewer, input: { disposition: "attachment" | "inline"; fileId: FileId; }, ): Promise { - const file = await fileStore.getRecord(bindings, caller.viewer, input.fileId); + const file = await fileStore.getRecord(bindings, caller, input.fileId); const threadId = requirePublicThreadFile(file); await admitPublicSessionCaller(bindings.DB, caller, threadId); - const response = await fileStore.streamContent( - bindings, - caller.viewer, - input.fileId, - input.disposition, - ); + const response = await fileStore.streamContent(bindings, caller, input.fileId, input.disposition); const headers = new Headers(response.headers); headers.set("Cache-Control", "no-store"); return new Response(response.body, { @@ -241,18 +226,18 @@ export async function downloadPublicThreadFileContent( export async function deletePublicThreadFile( bindings: ApiBindings, - caller: PublicApiCaller, + caller: AuthenticatedViewer, input: { fileId: FileId; threadId: PublicThreadId; }, ): Promise { const { sessionId } = await admitPublicThreadFileAccess(bindings, caller, input.threadId); - const file = await fileStore.getRecord(bindings, caller.viewer, input.fileId); + const file = await fileStore.getRecord(bindings, caller, input.fileId); assertPublicThreadFile(file, sessionId); - await fileStore.delete(bindings, caller.viewer, input.fileId); + await fileStore.delete(bindings, caller, input.fileId); await publishSessionResourceDelete({ bindings, resourceId: input.fileId, diff --git a/apps/api/src/modules/public-api/public-thread-metadata.ts b/apps/api/src/modules/public-api/public-thread-metadata.ts index 39b8fdf5..52599dc3 100644 --- a/apps/api/src/modules/public-api/public-thread-metadata.ts +++ b/apps/api/src/modules/public-api/public-thread-metadata.ts @@ -1,46 +1,23 @@ -import { parsePlatformId } from "@mosoo/id"; -import type { AppDeploymentId, AppDeploymentRunId, PersonalAccessTokenId } from "@mosoo/id"; - -const ACCESS_TOKEN_CREATED_BY_FIELDS = new Set(["token_id", "token_label"]); -const DEPLOYMENT_CAPABILITY_CREATED_BY_FIELDS = new Set([ - "binding_env", - "binding_name", - "deployment_id", - "deployment_run_id", - "kind", -]); +import type { PersonalAccessTokenId } from "@mosoo/id"; + const PUBLIC_API_FIELDS = new Set(["created_by", "idempotency_key", "source"]); -/** Thread created by an owner Access Token (the original Public Thread API caller). */ -export interface PublicApiThreadAccessTokenCreatedByMetadata { +export interface PublicApiThreadCreatedByMetadata { token_id: PersonalAccessTokenId; token_label: string; } -/** - * Thread created by a deployed App through its bound Agent capability. The - * Deployment is the visibility boundary for that identity: a capability only - * reads Threads whose `deployment_id` matches its own claims, so one App's - * deployment can never observe another deployment's or the owner's Threads. - */ -export interface PublicApiThreadDeploymentCapabilityCreatedByMetadata { - binding_env: string; - binding_name: string; - deployment_id: AppDeploymentId; - deployment_run_id: AppDeploymentRunId; - kind: "deployment_capability"; -} - -export type PublicApiThreadCreatedByMetadata = - | PublicApiThreadAccessTokenCreatedByMetadata - | PublicApiThreadDeploymentCapabilityCreatedByMetadata; - export interface PublicApiThreadMetadata { created_by: PublicApiThreadCreatedByMetadata; idempotency_key: string | null; source: "public_api"; } +export interface PublicApiThreadRecordMetadata { + idempotency_key: string | null; + source: "public_api"; +} + interface PublicApiThreadMetadataInput { createdBy: PublicApiThreadCreatedByMetadata; idempotencyKey: string | null; @@ -54,87 +31,6 @@ function hasOnlyFields(value: Record, fields: ReadonlySet fields.has(field)); } -export function isDeploymentCapabilityCreatedBy( - createdBy: PublicApiThreadCreatedByMetadata, -): createdBy is PublicApiThreadDeploymentCapabilityCreatedByMetadata { - return "kind" in createdBy && createdBy.kind === "deployment_capability"; -} - -function readAccessTokenCreatedByMetadata( - value: Record, -): PublicApiThreadAccessTokenCreatedByMetadata | null { - if (!hasOnlyFields(value, ACCESS_TOKEN_CREATED_BY_FIELDS)) { - return null; - } - - const tokenId = value["token_id"]; - const tokenLabel = value["token_label"]; - - if (typeof tokenId !== "string" || typeof tokenLabel !== "string") { - return null; - } - - try { - return { - token_id: parsePlatformId(tokenId, "Public API token ID"), - token_label: tokenLabel, - }; - } catch { - return null; - } -} - -function readDeploymentCapabilityCreatedByMetadata( - value: Record, -): PublicApiThreadDeploymentCapabilityCreatedByMetadata | null { - if (!hasOnlyFields(value, DEPLOYMENT_CAPABILITY_CREATED_BY_FIELDS)) { - return null; - } - - const bindingEnv = value["binding_env"]; - const bindingName = value["binding_name"]; - const deploymentId = value["deployment_id"]; - const deploymentRunId = value["deployment_run_id"]; - - if ( - typeof bindingEnv !== "string" || - bindingEnv.length === 0 || - typeof bindingName !== "string" || - bindingName.length === 0 || - typeof deploymentId !== "string" || - typeof deploymentRunId !== "string" - ) { - return null; - } - - try { - return { - binding_env: bindingEnv, - binding_name: bindingName, - deployment_id: parsePlatformId(deploymentId, "Public API deployment ID"), - deployment_run_id: parsePlatformId( - deploymentRunId, - "Public API deployment run ID", - ), - kind: "deployment_capability", - }; - } catch { - return null; - } -} - -function readCreatedByMetadata(value: unknown): PublicApiThreadCreatedByMetadata | null { - if (!isRecord(value)) { - return null; - } - - // Access Token threads predate the `kind` discriminator; their shape is - // exactly `{ token_id, token_label }` and must keep parsing unchanged. - return value["kind"] === "deployment_capability" - ? readDeploymentCapabilityCreatedByMetadata(value) - : readAccessTokenCreatedByMetadata(value); -} - export function createPublicApiThreadMetadata( input: PublicApiThreadMetadataInput, ): PublicApiThreadMetadata { @@ -145,7 +41,15 @@ export function createPublicApiThreadMetadata( }; } -export function parsePublicApiThreadMetadata(raw: string): PublicApiThreadMetadata | null { +/** + * Reads only the stable envelope needed to identify a stored Public Thread. + * Creator details are deliberately opaque here: authorization comes from the + * persisted account and Agent ownership columns, so historical records remain + * readable without retaining parsers for retired caller kinds. + */ +export function parsePublicApiThreadRecordMetadata( + raw: string, +): PublicApiThreadRecordMetadata | null { let parsed: unknown; try { @@ -168,15 +72,16 @@ export function parsePublicApiThreadMetadata(raw: string): PublicApiThreadMetada return null; } - const createdBy = readCreatedByMetadata(metadata["created_by"]); const idempotencyKey = metadata["idempotency_key"]; - if ((idempotencyKey !== null && typeof idempotencyKey !== "string") || createdBy === null) { + if ( + !isRecord(metadata["created_by"]) || + (idempotencyKey !== null && typeof idempotencyKey !== "string") + ) { return null; } return { - created_by: createdBy, idempotency_key: idempotencyKey, source: "public_api", }; diff --git a/apps/api/src/modules/public-api/public-thread-session-query.service.ts b/apps/api/src/modules/public-api/public-thread-session-query.service.ts index 610b4246..dfd72607 100644 --- a/apps/api/src/modules/public-api/public-thread-session-query.service.ts +++ b/apps/api/src/modules/public-api/public-thread-session-query.service.ts @@ -7,7 +7,7 @@ import { and, desc, eq, isNotNull, isNull, sql } from "drizzle-orm"; import { getAppDatabase } from "../../platform/db/drizzle"; import type { AgentRow } from "../agents/application/agent-types"; -import type { PublicApiCaller } from "../auth/application/public-api-caller.service"; +import type { AuthenticatedViewer } from "../auth/application/viewer-auth.service"; import { buildSessionSummaryFromJoinedRow, sessionSummaryWithLastRunColumns, @@ -16,7 +16,7 @@ import { admitAgentApiEndpointCaller } from "./agent-api-endpoint-admission.serv import { publicNotFound } from "./public-api-errors"; import { toPublicThreadSessionSummary } from "./public-thread-api-presenter"; import { toBackingSessionId } from "./public-thread-ids"; -import { parsePublicApiThreadMetadata } from "./public-thread-metadata"; +import { parsePublicApiThreadRecordMetadata } from "./public-thread-metadata"; import { toPublicThreadSummary } from "./public-thread-presenter"; interface PublicThreadSessionRow { @@ -36,43 +36,16 @@ interface PublicThreadSessionAdmission { session: PublicThreadSessionRow; } -/** - * Row conditions that bound which public Threads a caller can see. An Access - * Token sees every public-API Thread its account created; a deployment - * capability only sees Threads created through the same Deployment for the - * Agent and App its binding declared. - */ -export function publicThreadCallerScopeConditions(caller: PublicApiCaller): SQL[] { - const conditions: SQL[] = [ - eq(sessionsTable.creatorAccountId, caller.viewer.id), +function publicThreadCallerScopeConditions(caller: AuthenticatedViewer): SQL[] { + return [ + eq(sessionsTable.creatorAccountId, caller.id), sql`json_extract(${sessionsTable.metadataJson}, '$.public_api.source') = 'public_api'`, ]; - - if (caller.kind === "deployment_capability") { - conditions.push( - eq(sessionsTable.appId, caller.capability.appId), - eq(sessionsTable.agentId, caller.capability.agentId), - sql`json_extract(${sessionsTable.metadataJson}, '$.public_api.created_by.kind') = 'deployment_capability'`, - sql`json_extract(${sessionsTable.metadataJson}, '$.public_api.created_by.deployment_id') = ${caller.capability.deploymentId}`, - ); - } - - return conditions; -} - -/** - * A deployment capability may only address the Agent its binding declared; - * any other Agent id reads as missing, exactly like an Agent outside the App. - */ -function ensurePublicThreadAgentInScope(caller: PublicApiCaller, agentId: AgentId): void { - if (caller.kind === "deployment_capability" && caller.capability.agentId !== agentId) { - throw publicNotFound("Agent not found."); - } } async function getPublicThreadSessionAccess( database: D1Database, - caller: PublicApiCaller, + caller: AuthenticatedViewer, threadId: PublicThreadId, ): Promise { const sessionId = toBackingSessionId(threadId); @@ -95,7 +68,7 @@ async function getPublicThreadSessionAccess( throw publicNotFound("Thread not found."); } - const metadata = parsePublicApiThreadMetadata(row.metadata_json); + const metadata = parsePublicApiThreadRecordMetadata(row.metadata_json); if (!metadata || row.end_user_id === null) { throw publicNotFound("Thread not found."); @@ -114,11 +87,11 @@ async function getPublicThreadSessionAccess( export async function admitPublicSessionCaller( database: D1Database, - caller: PublicApiCaller, + caller: AuthenticatedViewer, threadId: PublicThreadId, ): Promise { const access = await getPublicThreadSessionAccess(database, caller, threadId); - const agent = await admitAgentApiEndpointCaller(database, caller.viewer, access.row.agent_id); + const agent = await admitAgentApiEndpointCaller(database, caller, access.row.agent_id); if (agent.appId !== access.row.app_id) { throw publicNotFound("Thread not found."); @@ -132,14 +105,13 @@ export async function admitPublicSessionCaller( export async function listAgentApiEndpointThreads( database: D1Database, - caller: PublicApiCaller, + caller: AuthenticatedViewer, input: { agentId: AgentId; archived: boolean | null; }, ): Promise { - ensurePublicThreadAgentInScope(caller, input.agentId); - await admitAgentApiEndpointCaller(database, caller.viewer, input.agentId); + await admitAgentApiEndpointCaller(database, caller, input.agentId); const filters: SQL[] = [ eq(sessionsTable.agentId, input.agentId), @@ -167,7 +139,7 @@ export async function listAgentApiEndpointThreads( return { threads: rows.flatMap((row) => { - const metadata = parsePublicApiThreadMetadata(row.metadata_json); + const metadata = parsePublicApiThreadRecordMetadata(row.metadata_json); if (!metadata || row.end_user_id === null) { return []; } diff --git a/apps/api/src/modules/public-api/public-thread-store.ts b/apps/api/src/modules/public-api/public-thread-store.ts index 93549cf2..26ca48eb 100644 --- a/apps/api/src/modules/public-api/public-thread-store.ts +++ b/apps/api/src/modules/public-api/public-thread-store.ts @@ -6,9 +6,15 @@ import { sessionsTable, } from "@mosoo/db"; import { parsePlatformId } from "@mosoo/id"; -import type { AccountId, AgentId, FileId, PublicThreadId, SessionId } from "@mosoo/id"; +import type { + AccountId, + AgentId, + FileId, + PersonalAccessTokenId, + PublicThreadId, + SessionId, +} from "@mosoo/id"; import { and, eq, sql } from "drizzle-orm"; -import type { SQL } from "drizzle-orm"; import type { ApiBindings } from "../../platform/cloudflare/worker-types"; import { getAppDatabase } from "../../platform/db/drizzle"; @@ -22,14 +28,8 @@ import type { SessionSummaryWithLastRunRow } from "../sessions/application/sessi import { deriveSessionTitleFromPrompt } from "../sessions/domain/session-title"; import { publicNotFound } from "./public-api-errors"; import { toBackingSessionId } from "./public-thread-ids"; -import { - isDeploymentCapabilityCreatedBy, - parsePublicApiThreadMetadata, -} from "./public-thread-metadata"; -import type { - PublicApiThreadCreatedByMetadata, - PublicApiThreadMetadata, -} from "./public-thread-metadata"; +import { parsePublicApiThreadRecordMetadata } from "./public-thread-metadata"; +import type { PublicApiThreadRecordMetadata } from "./public-thread-metadata"; export interface ThreadSnapshotRow extends SessionSummaryWithLastRunRow { creator_account_id: AccountId; @@ -39,7 +39,7 @@ export interface ThreadSnapshotRow extends SessionSummaryWithLastRunRow { export interface ThreadSnapshot { endUserId: string; - metadata: PublicApiThreadMetadata; + metadata: PublicApiThreadRecordMetadata; row: ThreadSnapshotRow; session: SessionSummary; } @@ -100,7 +100,7 @@ export async function getThreadSnapshot( throw publicNotFound("Thread not found."); } - const metadata = parsePublicApiThreadMetadata(row.metadata_json); + const metadata = parsePublicApiThreadRecordMetadata(row.metadata_json); if (!metadata || row.end_user_id === null) { throw publicNotFound("Thread not found."); @@ -118,27 +118,12 @@ export async function getThreadSnapshot( }; } -/** - * SQL condition selecting Threads stamped with the same creator identity. An - * Access Token owns its idempotency space; a deployment capability shares one - * space across the revisions of its Deployment so a retried create after a - * redeploy still replays instead of duplicating the Thread. - */ -export function publicThreadCreatedByCondition(createdBy: PublicApiThreadCreatedByMetadata): SQL { - if (isDeploymentCapabilityCreatedBy(createdBy)) { - return sql`json_extract(${sessionsTable.metadataJson}, '$.public_api.created_by.kind') = 'deployment_capability' - AND json_extract(${sessionsTable.metadataJson}, '$.public_api.created_by.deployment_id') = ${createdBy.deployment_id}`; - } - - return sql`json_extract(${sessionsTable.metadataJson}, '$.public_api.created_by.token_id') = ${createdBy.token_id}`; -} - export async function findPublicThreadSnapshotByIdempotencyKey( database: D1Database, input: { agentId: AgentId; - createdBy: PublicApiThreadCreatedByMetadata; idempotencyKey: string; + tokenId: PersonalAccessTokenId; }, ): Promise { const row = @@ -155,7 +140,7 @@ export async function findPublicThreadSnapshotByIdempotencyKey( and( eq(sessionsTable.agentId, input.agentId), sql`json_extract(${sessionsTable.metadataJson}, '$.public_api.source') = 'public_api'`, - publicThreadCreatedByCondition(input.createdBy), + sql`json_extract(${sessionsTable.metadataJson}, '$.public_api.created_by.token_id') = ${input.tokenId}`, sql`json_extract(${sessionsTable.metadataJson}, '$.public_api.idempotency_key') = ${input.idempotencyKey}`, ), ) @@ -166,7 +151,7 @@ export async function findPublicThreadSnapshotByIdempotencyKey( return null; } - const metadata = parsePublicApiThreadMetadata(row.metadata_json); + const metadata = parsePublicApiThreadRecordMetadata(row.metadata_json); if (!metadata || metadata.idempotency_key !== input.idempotencyKey || row.end_user_id === null) { return null; diff --git a/apps/api/src/modules/public-api/public-thread.types.ts b/apps/api/src/modules/public-api/public-thread.types.ts index 0b80db04..cbcb1317 100644 --- a/apps/api/src/modules/public-api/public-thread.types.ts +++ b/apps/api/src/modules/public-api/public-thread.types.ts @@ -1,7 +1,8 @@ import type { AgentId, FileId, PublicThreadId } from "@mosoo/id"; import type { ApiBindings } from "../../platform/cloudflare/worker-types"; -import type { PublicApiCaller } from "../auth/application/public-api-caller.service"; +import type { PersonalAccessTokenCaller } from "../auth/application/personal-access-token.service"; +import type { AuthenticatedViewer } from "../auth/application/viewer-auth.service"; export interface CreatePublicThreadInput { fileIds: FileId[]; @@ -12,7 +13,7 @@ export interface CreatePublicThreadInput { export interface CreatePublicThreadRequest { agentId: AgentId; bindings: ApiBindings; - caller: PublicApiCaller; + caller: PersonalAccessTokenCaller; executionContext: Pick | null; idempotencyKey: string | null; input: CreatePublicThreadInput; @@ -20,13 +21,13 @@ export interface CreatePublicThreadRequest { } export interface RetrievePublicThreadRequest { - caller: PublicApiCaller; + caller: AuthenticatedViewer; database: D1Database; threadId: PublicThreadId; } export interface ListPublicThreadEventsRequest { - caller: PublicApiCaller; + caller: AuthenticatedViewer; database: D1Database; limit: number; threadId: PublicThreadId; diff --git a/apps/api/src/modules/runtime/application/session-run.service.ts b/apps/api/src/modules/runtime/application/session-run.service.ts index 4576e912..856f50c3 100644 --- a/apps/api/src/modules/runtime/application/session-run.service.ts +++ b/apps/api/src/modules/runtime/application/session-run.service.ts @@ -1,8 +1,5 @@ export { createAgentSession } from "./session-runs/create-agent-session.service"; -export { - queueSessionRun, - SessionRunCreationGuardRejectedError, -} from "./session-runs/queue-run.service"; +export { queueSessionRun } from "./session-runs/queue-run.service"; export { rejectSessionPermissionRequests } from "./session-runs/session-permission-decision.service"; export { type QueueSessionRunsInput, diff --git a/apps/api/src/modules/runtime/application/session-runs/create-agent-session.service.ts b/apps/api/src/modules/runtime/application/session-runs/create-agent-session.service.ts index b0a8ea64..bf25fcd4 100644 --- a/apps/api/src/modules/runtime/application/session-runs/create-agent-session.service.ts +++ b/apps/api/src/modules/runtime/application/session-runs/create-agent-session.service.ts @@ -43,18 +43,10 @@ export interface CreateAgentSessionOptions { export interface AgentSessionMetadata { public_api?: { - created_by: - | { - token_id: string; - token_label: string; - } - | { - binding_env: string; - binding_name: string; - deployment_id: string; - deployment_run_id: string; - kind: "deployment_capability"; - }; + created_by: { + token_id: string; + token_label: string; + }; idempotency_key: string | null; source: "public_api"; }; diff --git a/apps/api/src/modules/runtime/application/session-runs/queue-run.service.ts b/apps/api/src/modules/runtime/application/session-runs/queue-run.service.ts index 2e45a8a7..617090fd 100644 --- a/apps/api/src/modules/runtime/application/session-runs/queue-run.service.ts +++ b/apps/api/src/modules/runtime/application/session-runs/queue-run.service.ts @@ -11,7 +11,6 @@ import type { SessionRunId, } from "@mosoo/id"; import { generateTraceId } from "@mosoo/observability"; -import type { SQL } from "drizzle-orm"; import { logError, logInfo } from "../../../../platform/cloudflare/logger"; import type { ApiBindings } from "../../../../platform/cloudflare/worker-types"; @@ -27,7 +26,6 @@ import type { AuthenticatedViewer } from "../../../auth/application/viewer-auth. import { resolveReadyEnvironmentPackageArtifact } from "../../../environments/application/environment-package-artifact.service"; import { fileStore } from "../../../files/application/file-store"; import { publishPersistedSessionRuntimeEvents } from "../../../sessions/application/session-event-write.service"; -import type { BoundCapabilityRunProvenance } from "../../domain/bound-capability-run-provenance"; import { getSupportedRuntimeId } from "../../domain/runtime-config"; import { commitQueuedSessionRunAdmission, @@ -35,7 +33,6 @@ import { isCattleTerminalCheckpointReadyForNextRun, } from "../../infrastructure/session-runs/session-run-admission.repository"; import { getActiveSessionRunSummary } from "../../infrastructure/session-runs/session-run-read.repository"; -import { SessionRunCreationGuardRejectedError } from "../../infrastructure/session-runs/session-run-store.repository"; import { createInsertedSessionRunSummary } from "../../infrastructure/session-runs/session-run-write.repository"; import { getSessionExecutionPlan } from "../session-definition/session-execution.repository"; import { dispatchQueuedSessionRun } from "./dispatch-queued-run.service"; @@ -55,10 +52,8 @@ class SessionActiveRunExistsError extends Error { interface QueueSessionRunInput { accessViewer?: AuthenticatedViewer; attachmentIds: FileId[]; - boundCapabilityProvenance?: BoundCapabilityRunProvenance; clientRequestId: string | null; prompt: string; - runCreationGuard?: SQL; session: { agent_id: AgentId; deployment_version_id: AgentDeploymentVersionId | null; @@ -86,8 +81,6 @@ export interface QueuedSessionRunState { updatedAt: string; } -export { SessionRunCreationGuardRejectedError }; - function createCheckpointPendingError(sessionId: SessionId) { return createApiError( API_ERROR_CODE.sessionRunCheckpointPending, @@ -187,9 +180,6 @@ export async function queueSessionRun(request: QueueSessionRunRequest): Promise< }, run: { agentId: input.session.agent_id, - ...(input.boundCapabilityProvenance === undefined - ? {} - : { boundCapabilityProvenance: input.boundCapabilityProvenance }), createdBy: viewerId, deploymentVersionId: input.session.deployment_version_id, deploymentVersionNumber: input.session.deployment_version_number, @@ -202,7 +192,6 @@ export async function queueSessionRun(request: QueueSessionRunRequest): Promise< traceId: createdRun.traceId, trigger: "user_prompt", }, - ...(input.runCreationGuard === undefined ? {} : { runCreationGuard: input.runCreationGuard }), session: { agentId: input.session.agent_id, appId: input.session.app_id, @@ -233,10 +222,6 @@ export async function queueSessionRun(request: QueueSessionRunRequest): Promise< throw createCheckpointPendingError(input.session.id); } - if (input.runCreationGuard !== undefined) { - throw new SessionRunCreationGuardRejectedError(); - } - throw new Error("Session cannot accept a new run."); } diff --git a/apps/api/src/modules/runtime/application/session-runs/send-agent-session-events.service.ts b/apps/api/src/modules/runtime/application/session-runs/send-agent-session-events.service.ts index b01c269a..da92b0cc 100644 --- a/apps/api/src/modules/runtime/application/session-runs/send-agent-session-events.service.ts +++ b/apps/api/src/modules/runtime/application/session-runs/send-agent-session-events.service.ts @@ -23,7 +23,6 @@ import type { SessionActionAuthorization } from "../../../sessions/domain/sessio import { resolveSessionActionCreatorFlag } from "../../../sessions/domain/session-access.policy"; import { toSessionLifecycleStatusForRunStatus } from "../../../sessions/domain/session-lifecycle"; import { deriveSessionTitleFromPrompt } from "../../../sessions/domain/session-title"; -import type { BoundCapabilityRunAdmission } from "../../domain/bound-capability-run-provenance"; import { getActiveSessionRunId } from "../../infrastructure/session-runs/session-run-store.repository"; import { cancelRun } from "./cancel-run.service"; import type { QueuedSessionRunState } from "./queue-run.service"; @@ -39,7 +38,6 @@ interface SendAgentSessionEventsInput { interface AgentSessionEventsOptions { accessViewer?: AuthenticatedViewer; actionAuthorization?: SessionActionAuthorization; - boundCapability?: BoundCapabilityRunAdmission; cachedState?: SessionLiveState | null; } diff --git a/apps/api/src/modules/runtime/application/session-runs/start-runs.service.ts b/apps/api/src/modules/runtime/application/session-runs/start-runs.service.ts index b3e45987..8e0e200e 100644 --- a/apps/api/src/modules/runtime/application/session-runs/start-runs.service.ts +++ b/apps/api/src/modules/runtime/application/session-runs/start-runs.service.ts @@ -5,7 +5,6 @@ import type { AccountId, FileId, AppId, SessionId } from "@mosoo/id"; import type { ApiBindings } from "../../../../platform/cloudflare/worker-types"; import type { AuthenticatedViewer } from "../../../auth/application/viewer-auth.service"; import { getActiveAppSessionQueueAccess } from "../../../sessions/domain/session-access.policy"; -import type { BoundCapabilityRunAdmission } from "../../domain/bound-capability-run-provenance"; import { queueSessionRun } from "./queue-run.service"; import type { QueuedSessionRunState } from "./queue-run.service"; @@ -32,7 +31,6 @@ export interface QueueSessionRunsOutput { export interface StartRunsOptions { accessViewer?: AuthenticatedViewer; - boundCapability?: BoundCapabilityRunAdmission; } export interface StartRunsRequest { @@ -88,7 +86,6 @@ async function queueRunRequest( prompt, session, ...(context.options.accessViewer ? { accessViewer: context.options.accessViewer } : {}), - ...context.options.boundCapability, }, requestUrl: context.requestUrl, viewer: context.viewer, diff --git a/apps/api/src/modules/runtime/domain/bound-capability-run-provenance.ts b/apps/api/src/modules/runtime/domain/bound-capability-run-provenance.ts deleted file mode 100644 index ee7d41a8..00000000 --- a/apps/api/src/modules/runtime/domain/bound-capability-run-provenance.ts +++ /dev/null @@ -1,26 +0,0 @@ -import type { AgentId, AppDeploymentId, AppDeploymentRunId, AppId } from "@mosoo/id"; -import type { SQL } from "drizzle-orm"; - -/** - * Immutable authorization facts captured when a bound Agent capability accepts - * a Run. The raw URL and signed token are deliberately excluded. - */ -export interface BoundCapabilityRunProvenance { - agentId: AgentId; - appId: AppId; - bindingEnv: string; - bindingName: string; - deploymentId: AppDeploymentId; - deploymentRunId: AppDeploymentRunId; -} - -/** - * What a bound capability attaches to every Run it starts: the provenance - * recorded on the Run row plus the D1 authority condition repeated inside the - * Run insert, so a deletion or successful revision replacement that commits - * mid-request cannot create an owner-billed Run. - */ -export interface BoundCapabilityRunAdmission { - boundCapabilityProvenance: BoundCapabilityRunProvenance; - runCreationGuard: SQL; -} diff --git a/apps/api/src/modules/runtime/infrastructure/session-runs/session-run-admission.repository.ts b/apps/api/src/modules/runtime/infrastructure/session-runs/session-run-admission.repository.ts index d00162aa..e9207aef 100644 --- a/apps/api/src/modules/runtime/infrastructure/session-runs/session-run-admission.repository.ts +++ b/apps/api/src/modules/runtime/infrastructure/session-runs/session-run-admission.repository.ts @@ -20,7 +20,6 @@ import type { } from "@mosoo/id"; import type { RuntimeEventEnvelope } from "@mosoo/runtime-events"; import { and, eq, exists, inArray, isNull, ne, notExists, or, sql } from "drizzle-orm"; -import type { SQL } from "drizzle-orm"; import { getAppDatabase, @@ -30,13 +29,11 @@ import { import type { AppDatabase } from "../../../../platform/db/drizzle"; import type { PreparedApiCommand } from "../../../api-command/application/api-command-ledger"; import { createSessionRuntimeEventProjection } from "../../../sessions/domain/session-runtime-event-projection"; -import type { BoundCapabilityRunProvenance } from "../../domain/bound-capability-run-provenance"; import { ACTIVE_SESSION_RUN_STATUSES } from "../../domain/session-run-lifecycle.machine"; import { createSessionStatusTransitionPatch } from "./session-lifecycle-projection.repository"; interface QueuedRunAdmissionRecord { agentId: AgentId; - boundCapabilityProvenance?: BoundCapabilityRunProvenance; createdBy: AccountId; deploymentVersionId: AgentDeploymentVersionId | null; deploymentVersionNumber: number | null; @@ -63,7 +60,6 @@ export interface CommitQueuedSessionRunAdmissionInput { events: readonly RuntimeEventEnvelope[]; message: QueuedMessageAdmissionRecord; run: QueuedRunAdmissionRecord; - runCreationGuard?: SQL; session: { agentId: AgentId; appId: AppId; @@ -152,7 +148,6 @@ function claimableSessionPredicate(db: AppDatabase, input: CommitQueuedSessionRu ), ), ), - input.runCreationGuard ?? sql`TRUE`, ); } @@ -238,30 +233,6 @@ function createRunInsertQuery(db: AppDatabase, input: CommitQueuedSessionRunAdmi db .select({ agentId: selectedValue(input.run.agentId, "agent_id"), - boundCapabilityAgentId: selectedValue( - input.run.boundCapabilityProvenance?.agentId ?? null, - "bound_capability_agent_id", - ), - boundCapabilityAppId: selectedValue( - input.run.boundCapabilityProvenance?.appId ?? null, - "bound_capability_app_id", - ), - boundCapabilityBindingEnv: selectedValue( - input.run.boundCapabilityProvenance?.bindingEnv ?? null, - "bound_capability_binding_env", - ), - boundCapabilityBindingName: selectedValue( - input.run.boundCapabilityProvenance?.bindingName ?? null, - "bound_capability_binding_name", - ), - boundCapabilityDeploymentId: selectedValue( - input.run.boundCapabilityProvenance?.deploymentId ?? null, - "bound_capability_deployment_id", - ), - boundCapabilityDeploymentRunId: selectedValue( - input.run.boundCapabilityProvenance?.deploymentRunId ?? null, - "bound_capability_deployment_run_id", - ), completedAt: selectedValue(null, "completed_at"), createdAt: selectedValue(input.run.timestampMs, "created_at"), createdByAccountId: selectedValue(input.run.createdBy, "created_by_account_id"), diff --git a/apps/api/src/modules/runtime/infrastructure/session-runs/session-run-store.repository.ts b/apps/api/src/modules/runtime/infrastructure/session-runs/session-run-store.repository.ts index 7f190df5..64ea2a69 100644 --- a/apps/api/src/modules/runtime/infrastructure/session-runs/session-run-store.repository.ts +++ b/apps/api/src/modules/runtime/infrastructure/session-runs/session-run-store.repository.ts @@ -7,7 +7,6 @@ export { export { cancelActiveSessionRunsForRuntimeOperation, createSessionRunRecordIfSessionIdle, - SessionRunCreationGuardRejectedError, setSessionRunStatus, } from "./session-run-write.repository"; export type { SessionRunTransitionOutcome } from "./session-run-write.repository"; diff --git a/apps/api/src/modules/runtime/infrastructure/session-runs/session-run-write.repository.ts b/apps/api/src/modules/runtime/infrastructure/session-runs/session-run-write.repository.ts index ce1e4c3f..7be1a0b6 100644 --- a/apps/api/src/modules/runtime/infrastructure/session-runs/session-run-write.repository.ts +++ b/apps/api/src/modules/runtime/infrastructure/session-runs/session-run-write.repository.ts @@ -18,7 +18,6 @@ import type { } from "@mosoo/id"; import { generateTraceId } from "@mosoo/observability"; import { and, eq, exists, inArray, notInArray, sql } from "drizzle-orm"; -import type { SQL } from "drizzle-orm"; import { createErrorLogContext, logInfo, logWarn } from "../../../../platform/cloudflare/logger"; import { @@ -28,7 +27,6 @@ import { } from "../../../../platform/db/drizzle"; import { currentTimestampMs, toIsoString } from "../../../../time"; import { toSessionLifecycleStatusForRunStatus } from "../../../sessions/domain/session-lifecycle"; -import type { BoundCapabilityRunProvenance } from "../../domain/bound-capability-run-provenance"; import { ACTIVE_SESSION_RUN_STATUSES, decideSessionRunTransition, @@ -79,17 +77,6 @@ type SessionRunTransitionSource = const SESSION_RUN_STATUS_WRITE_BATCH_SIZE = 50; -/** - * The caller supplied an atomic predicate for Run creation and it no longer - * held when the INSERT statement executed. No Run record was created. - */ -export class SessionRunCreationGuardRejectedError extends Error { - constructor() { - super("Session Run creation authorization changed before the Run could be inserted."); - this.name = "SessionRunCreationGuardRejectedError"; - } -} - interface LoadedSessionRunLifecycleRow { completed_at: number | null; created_at: number; @@ -340,13 +327,11 @@ export async function createSessionRunRecordIfSessionIdle( database: D1Database, input: { agentId: AgentId; - boundCapabilityProvenance?: BoundCapabilityRunProvenance; createdBy: AccountId; deploymentVersionId?: AgentDeploymentVersionId | null; deploymentVersionNumber?: number | null; model?: string | null; provider?: string | null; - runCreationGuard?: SQL; runtimeId?: string | null; sessionId: SessionId; startedAt?: number | null; @@ -378,12 +363,6 @@ export async function createSessionRunRecordIfSessionIdle( trigger, status, agent_id, - bound_capability_agent_id, - bound_capability_app_id, - bound_capability_binding_env, - bound_capability_binding_name, - bound_capability_deployment_id, - bound_capability_deployment_run_id, deployment_version_id, deployment_version_number, runtime_id, @@ -410,12 +389,6 @@ export async function createSessionRunRecordIfSessionIdle( ${input.trigger}, ${input.status}, ${input.agentId}, - ${input.boundCapabilityProvenance?.agentId ?? null}, - ${input.boundCapabilityProvenance?.appId ?? null}, - ${input.boundCapabilityProvenance?.bindingEnv ?? null}, - ${input.boundCapabilityProvenance?.bindingName ?? null}, - ${input.boundCapabilityProvenance?.deploymentId ?? null}, - ${input.boundCapabilityProvenance?.deploymentRunId ?? null}, ${input.deploymentVersionId ?? null}, ${input.deploymentVersionNumber ?? null}, ${input.runtimeId ?? null}, @@ -446,7 +419,6 @@ export async function createSessionRunRecordIfSessionIdle( WHERE session_id = ${input.sessionId} AND ${sql.raw(buildActiveSessionRunStatusFilter())} ) - AND ${input.runCreationGuard ?? sql`TRUE`} RETURNING id `, )) ?? null; @@ -455,10 +427,6 @@ export async function createSessionRunRecordIfSessionIdle( const activeRun = await getActiveSessionRunSummary(database, input.sessionId); if (!activeRun) { - if (input.runCreationGuard !== undefined) { - throw new SessionRunCreationGuardRejectedError(); - } - throw new Error("Session cannot accept a new run."); } diff --git a/apps/api/src/modules/sessions/graphql/session-graphql.ts b/apps/api/src/modules/sessions/graphql/session-graphql.ts index a02e61d8..11843b57 100644 --- a/apps/api/src/modules/sessions/graphql/session-graphql.ts +++ b/apps/api/src/modules/sessions/graphql/session-graphql.ts @@ -1,9 +1,8 @@ import { parsePlatformId } from "@mosoo/id"; -import type { AgentId, AppId, SessionId, SessionRunId } from "@mosoo/id"; +import type { AgentId, AppId, SessionId } from "@mosoo/id"; import type { GraphQLModule } from "../../../adapters/graphql/graphql-module"; import { sessionGraphQLSpec } from "../../../adapters/graphql/graphql-module-specs"; -import { getBoundCapabilityRunProvenance } from "../../public-api/bound-capability-run-provenance.service"; import { createAgentSession, sendAgentSessionEvents, @@ -39,11 +38,6 @@ interface SessionArgs { sessionId: string; } -interface BoundCapabilityRunProvenanceArgs { - appId: string; - runId: string; -} - interface SessionProcessEventsArgs extends SessionArgs { limit?: number | null; } @@ -109,10 +103,6 @@ function readSessionId(value: string): SessionId { return parsePlatformId(value, "Session ID"); } -function readSessionRunId(value: string): SessionRunId { - return parsePlatformId(value, "Session Run ID"); -} - export const sessionGraphQLModule = { ...sessionGraphQLSpec, authenticatedMutationResolvers: { @@ -206,15 +196,6 @@ export const sessionGraphQLModule = { appId: readAppId(args.appId), sessionId: readSessionId(args.sessionId), }), - boundCapabilityRunProvenance: async ( - _parent, - args: BoundCapabilityRunProvenanceArgs, - context, - ) => - getBoundCapabilityRunProvenance(context.bindings.DB, context.viewer, { - appId: readAppId(args.appId), - runId: readSessionRunId(args.runId), - }), agentSessionList: async (_parent, args: AgentSessionListArgs, context) => listAgentSessions(context.bindings.DB, context.viewer, { agentId: readAgentId(args.agentId), diff --git a/apps/api/src/platform/db/drizzle.ts b/apps/api/src/platform/db/drizzle.ts index e435c746..0c754b65 100644 --- a/apps/api/src/platform/db/drizzle.ts +++ b/apps/api/src/platform/db/drizzle.ts @@ -4,9 +4,6 @@ import { agentMcpBindingsTable, agentsTable, agentSkillsTable, - appDeploymentRunsTable, - appDeploymentSecretsTable, - appDeploymentsTable, apiCommandsTable, authAccountsTable, authSessionsTable, @@ -59,9 +56,6 @@ const schema = { agentMcpBindingsTable, agentsTable, agentSkillsTable, - appDeploymentRunsTable, - appDeploymentSecretsTable, - appDeploymentsTable, apiCommandsTable, authAccountsTable, authSessionsTable, diff --git a/apps/api/src/platform/errors.ts b/apps/api/src/platform/errors.ts index 08047acb..194ea36d 100644 --- a/apps/api/src/platform/errors.ts +++ b/apps/api/src/platform/errors.ts @@ -39,7 +39,6 @@ export const API_ERROR_CODE = { agentPublishNotReady: "AGENT_PUBLISH_NOT_READY", agentPublishPersonalMcp: "AGENT_PUBLISH_PERSONAL_MCP", agentSessionNotReady: "AGENT_SESSION_NOT_READY", - appDeploymentCleanupFailed: "APP_DEPLOYMENT_CLEANUP_FAILED", environmentArtifactFailed: "ENVIRONMENT_ARTIFACT_FAILED", environmentArtifactPreparing: "ENVIRONMENT_ARTIFACT_PREPARING", forbidden: "FORBIDDEN", @@ -68,7 +67,6 @@ const API_ERROR_STATUS_BY_CODE = { [API_ERROR_CODE.agentPublishNotReady]: API_ERROR_STATUS.badRequest, [API_ERROR_CODE.agentPublishPersonalMcp]: API_ERROR_STATUS.badRequest, [API_ERROR_CODE.agentSessionNotReady]: API_ERROR_STATUS.badRequest, - [API_ERROR_CODE.appDeploymentCleanupFailed]: API_ERROR_STATUS.badGateway, [API_ERROR_CODE.environmentArtifactFailed]: API_ERROR_STATUS.conflict, [API_ERROR_CODE.environmentArtifactPreparing]: API_ERROR_STATUS.conflict, [API_ERROR_CODE.forbidden]: API_ERROR_STATUS.forbidden, diff --git a/apps/api/tests/api-web-boundary.test.ts b/apps/api/tests/api-web-boundary.test.ts index 1119aa43..c6e2cf1c 100644 --- a/apps/api/tests/api-web-boundary.test.ts +++ b/apps/api/tests/api-web-boundary.test.ts @@ -151,22 +151,6 @@ describe("API to web boundary", () => { expect(fields.ownerCostCard).toBeUndefined(); }); - test("keeps bound capability Run provenance behind an App-scoped audit query", () => { - const schema = createGraphQLSchema(); - const query = schema.getQueryType(); - - if (!query) { - throw new Error("Expected Query in the GraphQL schema."); - } - - const provenance = query.getFields().boundCapabilityRunProvenance; - - expect(provenance).toBeDefined(); - expect(String(provenance?.type)).toBe("BoundCapabilityRunProvenance"); - expect(String(provenance?.args.find((arg) => arg.name === "appId")?.type)).toBe("ULID!"); - expect(String(provenance?.args.find((arg) => arg.name === "runId")?.type)).toBe("ULID!"); - }); - test("keeps file GraphQL scope details compatible", () => { const schema = createGraphQLSchema(); const fileRecord = schema.getType("FileRecord"); diff --git a/apps/api/tests/app-agent-binding-resolution.test.ts b/apps/api/tests/app-agent-binding-resolution.test.ts deleted file mode 100644 index 018d83bf..00000000 --- a/apps/api/tests/app-agent-binding-resolution.test.ts +++ /dev/null @@ -1,54 +0,0 @@ -import { describe, expect, test } from "bun:test"; - -import { - AppAgentBindingResolutionError, - resolveAppAgentBindings, -} from "../src/modules/apps/application/app-agent-binding-resolution"; -import type { AppDeploymentAgentBinding } from "../src/modules/apps/application/app-deployment-detector"; - -const BINDINGS: AppDeploymentAgentBinding[] = [ - { env: "ROADMAP_THREAD_URL", expose: "public_thread", name: "roadmap" }, - { env: "TRIAGE_THREAD_URL", expose: "public_thread", name: "triage" }, -]; - -describe("resolveAppAgentBindings", () => { - test("resolves every binding to its published agent", () => { - expect( - resolveAppAgentBindings(BINDINGS, [ - { id: "agt_3kf", name: "roadmap", published: true }, - { id: "agt_9wz", name: "triage", published: true }, - ]), - ).toEqual([ - { - agentId: "agt_3kf", - envVar: "ROADMAP_THREAD_URL", - expose: "public_thread", - name: "roadmap", - }, - { agentId: "agt_9wz", envVar: "TRIAGE_THREAD_URL", expose: "public_thread", name: "triage" }, - ]); - }); - - test("fails fast when a bound agent is missing", () => { - expect(() => - resolveAppAgentBindings(BINDINGS, [{ id: "agt_3kf", name: "roadmap", published: true }]), - ).toThrow(AppAgentBindingResolutionError); - }); - - test("fails fast with the published code when a bound agent is not live", () => { - try { - resolveAppAgentBindings(BINDINGS, [ - { id: "agt_3kf", name: "roadmap", published: true }, - { id: "agt_9wz", name: "triage", published: false }, - ]); - throw new Error("expected resolution to throw"); - } catch (error) { - expect(error).toBeInstanceOf(AppAgentBindingResolutionError); - expect((error as AppAgentBindingResolutionError).code).toBe("deployment_agent_not_published"); - } - }); - - test("resolves to an empty list when there are no bindings", () => { - expect(resolveAppAgentBindings([], [])).toEqual([]); - }); -}); diff --git a/apps/api/tests/app-agent-bound-call.test.ts b/apps/api/tests/app-agent-bound-call.test.ts deleted file mode 100644 index 41ee70be..00000000 --- a/apps/api/tests/app-agent-bound-call.test.ts +++ /dev/null @@ -1,312 +0,0 @@ -import { describe, expect, test } from "bun:test"; - -import type { SessionRunStatus } from "@mosoo/contracts/session-run"; -import { parsePlatformId } from "@mosoo/id"; -import type { - AccountId, - AgentDeploymentVersionId, - AgentId, - AppDeploymentId, - AppDeploymentRunId, - AppId, -} from "@mosoo/id"; - -import type { AgentRow } from "../src/modules/agents/application/agent-types"; -import { - getBoundAgentServabilityFailure, - isTerminalRunStatus, - parseBoundAgentCallBody, - selectBoundAgentReply, - verifyBoundAgentCapability, - waitForTerminalRun, -} from "../src/modules/public-api/app-agent-bound-call"; -import { - BoundAgentCallError, - DEPLOYMENT_AGENT_CALL_TIMEOUT_ERROR_CODE, - DEPLOYMENT_AGENT_FINAL_OUTPUT_MISSING_ERROR_CODE, - DEPLOYMENT_AGENT_NEEDS_INPUT_ERROR_CODE, - DEPLOYMENT_AGENT_RUN_FAILED_ERROR_CODE, -} from "../src/modules/public-api/app-agent-bound-errors"; -import { mintAppAgentCapabilityToken } from "../src/modules/public-api/app-agent-capability"; -import type { AppAgentCapabilityClaims } from "../src/modules/public-api/app-agent-capability"; -import { PublicApiError } from "../src/modules/public-api/public-api-errors"; - -const SECRET = "bound-test-secret"; -const NOW = 5_000_000; -const AGENT_ID = parsePlatformId("01J00000000000000000000009"); -const APP_ID = parsePlatformId("01J0000000000000000000000Q"); -const DEPLOYMENT_ID = parsePlatformId("01J0000000000000000000000D"); -const DEPLOYMENT_RUN_ID = parsePlatformId("01J0000000000000000000000R"); -const OWNER_ID = parsePlatformId("01J00000000000000000000001"); -const AGENT_VERSION_ID = parsePlatformId("01J0000000000000000000000A"); - -function claims(overrides: Partial = {}): AppAgentCapabilityClaims { - return { - agentId: AGENT_ID, - appId: APP_ID, - binding: { env: "MOSOO_AGENT", expose: "public_thread", name: "Bound Agent" }, - deploymentId: DEPLOYMENT_ID, - deploymentRunId: DEPLOYMENT_RUN_ID, - exp: NOW + 60_000, - ...overrides, - }; -} - -function agent(overrides: Partial = {}): AgentRow { - return { - appId: APP_ID, - configJson: "{}", - createdAt: 0, - description: null, - environmentId: null, - id: AGENT_ID, - kind: "pet", - liveDeploymentVersionId: AGENT_VERSION_ID, - model: "gpt-5.4", - name: "Bound Agent", - ownerId: OWNER_ID, - prompt: "Help.", - provider: "openai", - runtimeId: "openai-runtime", - status: "published", - updatedAt: 0, - visibility: "private", - ...overrides, - }; -} - -describe("verifyBoundAgentCapability (capability verify-on-route)", () => { - test("rejects a malformed token", async () => { - const rejection = await verifyBoundAgentCapability(SECRET, "not-a-token", NOW).catch( - (error: unknown) => error, - ); - expect(rejection).toBeInstanceOf(PublicApiError); - expect((rejection as PublicApiError).status).toBe(401); - }); - - test("rejects a token signed with a different secret", async () => { - const token = await mintAppAgentCapabilityToken("other-secret", claims()); - await expect(verifyBoundAgentCapability(SECRET, token, NOW)).rejects.toBeInstanceOf( - PublicApiError, - ); - }); - - test("rejects an expired token", async () => { - const token = await mintAppAgentCapabilityToken(SECRET, claims({ exp: NOW })); - await expect(verifyBoundAgentCapability(SECRET, token, NOW)).rejects.toBeInstanceOf( - PublicApiError, - ); - }); - - test("returns claims for a valid token", async () => { - const token = await mintAppAgentCapabilityToken(SECRET, claims()); - expect(await verifyBoundAgentCapability(SECRET, token, NOW)).toEqual(claims()); - }); -}); - -describe("bound Agent servability", () => { - test("classifies unpublished Agents for capability audit", () => { - expect(getBoundAgentServabilityFailure(agent({ status: "draft" }), claims())).toBe( - "agent_unpublished", - ); - }); - - test("classifies a renamed binding target without treating it as unpublished", () => { - expect(getBoundAgentServabilityFailure(agent({ name: "Renamed" }), claims())).toBe( - "agent_mismatched", - ); - }); -}); - -describe("parseBoundAgentCallBody", () => { - test("accepts a `message` field", () => { - expect(parseBoundAgentCallBody({ message: "hello" })).toEqual({ message: "hello" }); - }); - - test("accepts an `input` alias", () => { - expect(parseBoundAgentCallBody({ input: "yo" })).toEqual({ message: "yo" }); - }); - - test("trims surrounding whitespace", () => { - expect(parseBoundAgentCallBody({ message: " spaced " })).toEqual({ message: "spaced" }); - }); - - test("falls back to input when message is an empty string", () => { - expect(parseBoundAgentCallBody({ input: "hi", message: "" })).toEqual({ message: "hi" }); - }); - - test("rejects a missing message", () => { - expect(() => parseBoundAgentCallBody({})).toThrow(PublicApiError); - }); - - test("rejects a blank message", () => { - expect(() => parseBoundAgentCallBody({ message: " " })).toThrow(PublicApiError); - }); - - test("rejects a non-object body", () => { - expect(() => parseBoundAgentCallBody("nope")).toThrow(PublicApiError); - expect(() => parseBoundAgentCallBody(null)).toThrow(PublicApiError); - expect(() => parseBoundAgentCallBody(["a"])).toThrow(PublicApiError); - }); -}); - -describe("isTerminalRunStatus", () => { - test("classifies terminal vs active statuses", () => { - const terminal: SessionRunStatus[] = ["completed", "failed", "cancelled", "expired"]; - const active: SessionRunStatus[] = ["queued", "booting", "running", "waiting_input"]; - for (const status of terminal) { - expect(isTerminalRunStatus(status)).toBe(true); - } - for (const status of active) { - expect(isTerminalRunStatus(status)).toBe(false); - } - }); -}); - -describe("waitForTerminalRun", () => { - test("returns the terminal run without delaying when already terminal", async () => { - let delays = 0; - const run = await waitForTerminalRun( - { - delay: async () => { - delays += 1; - }, - now: () => 0, - readRun: async () => ({ status: "completed" as SessionRunStatus }), - }, - { pollIntervalMs: 1_000, timeoutMs: 25_000 }, - ); - expect(run.status).toBe("completed"); - expect(delays).toBe(0); - }); - - test("rejects with needs-input when the run parks on waiting_input", async () => { - const error = await waitForTerminalRun( - { - delay: async () => undefined, - now: () => 0, - readRun: async () => ({ status: "waiting_input" as SessionRunStatus }), - }, - { pollIntervalMs: 1_000, timeoutMs: 25_000 }, - ).catch((caught: unknown) => caught); - expect(error).toBeInstanceOf(BoundAgentCallError); - expect((error as BoundAgentCallError).code).toBe(DEPLOYMENT_AGENT_NEEDS_INPUT_ERROR_CODE); - }); - - test("polls until the run reaches a terminal state", async () => { - const statuses: SessionRunStatus[] = ["running", "running", "completed"]; - let index = 0; - let clock = 0; - let delays = 0; - const run = await waitForTerminalRun( - { - delay: async (ms) => { - clock += ms; - delays += 1; - }, - now: () => clock, - readRun: async () => { - const status = statuses[index] ?? "completed"; - index += 1; - return { status }; - }, - }, - { pollIntervalMs: 1_000, timeoutMs: 25_000 }, - ); - expect(run.status).toBe("completed"); - expect(delays).toBe(2); - }); - - test("throws a timeout error once the budget elapses", async () => { - let clock = 0; - const rejection = await waitForTerminalRun( - { - delay: async (ms) => { - clock += ms; - }, - now: () => clock, - readRun: async () => ({ status: "running" as SessionRunStatus }), - }, - { pollIntervalMs: 1_000, timeoutMs: 5_000 }, - ).catch((error: unknown) => error); - expect(rejection).toBeInstanceOf(BoundAgentCallError); - expect((rejection as BoundAgentCallError).code).toBe(DEPLOYMENT_AGENT_CALL_TIMEOUT_ERROR_CODE); - expect((rejection as BoundAgentCallError).status).toBe(504); - }); - - test("treats a missing run row as non-terminal until the timeout", async () => { - let clock = 0; - const rejection = await waitForTerminalRun( - { - delay: async (ms) => { - clock += ms; - }, - now: () => clock, - readRun: async () => null, - }, - { pollIntervalMs: 1_000, timeoutMs: 2_000 }, - ).catch((error: unknown) => error); - expect(rejection).toBeInstanceOf(BoundAgentCallError); - }); -}); - -describe("selectBoundAgentReply (final-output extraction)", () => { - test("returns the joined final output text on a completed run", () => { - expect( - selectBoundAgentReply({ - finalOutput: { text: "the answer" }, - run: { error: null, status: "completed" }, - }), - ).toEqual({ reply: "the answer" }); - }); - - test("rejects a completed run with no canonical final output", () => { - expect(() => - selectBoundAgentReply({ - finalOutput: null, - run: { error: null, status: "completed" }, - }), - ).toThrow(BoundAgentCallError); - - try { - selectBoundAgentReply({ - finalOutput: null, - run: { error: null, status: "completed" }, - }); - } catch (error) { - expect(error).toMatchObject({ - code: DEPLOYMENT_AGENT_FINAL_OUTPUT_MISSING_ERROR_CODE, - status: 503, - }); - } - }); - - test("surfaces the run error on a failed run", () => { - const rejection = (() => { - try { - selectBoundAgentReply({ - finalOutput: null, - run: { - error: { code: "boom", details: {}, message: "it broke", retryable: false }, - status: "failed", - }, - }); - return null; - } catch (error: unknown) { - return error; - } - })(); - expect(rejection).toBeInstanceOf(BoundAgentCallError); - expect((rejection as BoundAgentCallError).code).toBe(DEPLOYMENT_AGENT_RUN_FAILED_ERROR_CODE); - expect((rejection as BoundAgentCallError).message).toBe("it broke"); - }); - - test("fails a cancelled run even without an error payload", () => { - expect(() => - selectBoundAgentReply({ - finalOutput: null, - run: { error: null, status: "cancelled" }, - }), - ).toThrow(BoundAgentCallError); - }); -}); diff --git a/apps/api/tests/app-agent-bound-run-revocation.test.ts b/apps/api/tests/app-agent-bound-run-revocation.test.ts deleted file mode 100644 index ee480edb..00000000 --- a/apps/api/tests/app-agent-bound-run-revocation.test.ts +++ /dev/null @@ -1,380 +0,0 @@ -import { describe, expect, test } from "bun:test"; - -import { parsePlatformId } from "@mosoo/id"; -import type { AgentDeploymentVersionId, SessionId } from "@mosoo/id"; -import { graphql } from "graphql"; - -import { createGraphQLSchema } from "../src/adapters/graphql/create-graphql-schema"; -import type { GraphQLContext } from "../src/adapters/graphql/graphql-context"; -import { createDeploymentAgentCapabilityRunCreationGuard } from "../src/modules/apps/application/app-deployment-capability-authority.service"; -import { getAccountViewer } from "../src/modules/auth/application/public-api-caller.service"; -import type { AuthenticatedViewer } from "../src/modules/auth/application/viewer-auth.service"; -import type { AppAgentCapabilityClaims } from "../src/modules/public-api/app-agent-capability"; -import { - queueSessionRun, - SessionRunCreationGuardRejectedError, -} from "../src/modules/runtime/application/session-run.service"; -import type { ApiBindings } from "../src/platform/cloudflare/worker-types"; -import { - PUBLIC_API_TEST_IDS, - createPublicHttpContractDatabase, - createPublicHttpTestBindings, - createTestExecutionContext, - insertOwnerSession, -} from "./helpers/public-api-http-test-fixture"; -import type { SqliteD1Database } from "./helpers/public-api-http-test-fixture"; - -const DEPLOYMENT_ID = "01J0000000000000000000000D"; -const DEPLOYMENT_RUN_ID = "01J0000000000000000000000R"; - -const CLAIMS: AppAgentCapabilityClaims = { - agentId: PUBLIC_API_TEST_IDS.agent, - appId: PUBLIC_API_TEST_IDS.app, - binding: { - env: "MOSOO_PUBLIC_AGENT", - expose: "public_thread", - name: "Public API Agent", - }, - deploymentId: DEPLOYMENT_ID, - deploymentRunId: DEPLOYMENT_RUN_ID, - exp: Date.now() + 60_000, -}; - -async function insertDeploymentAuthority(database: SqliteD1Database): Promise { - database.execute(` - CREATE TABLE app_deployment ( - app_id text NOT NULL, - deleted_at integer, - id text PRIMARY KEY NOT NULL - ); - - CREATE TABLE app_deployment_run ( - app_id text NOT NULL, - deployment_id text NOT NULL, - id text PRIMARY KEY NOT NULL, - plan_json text, - status text NOT NULL - ); - - CREATE INDEX app_deployment_run_deployment_id_idx - ON app_deployment_run (deployment_id, id); - `); - - await database - .prepare("INSERT INTO app_deployment (app_id, deleted_at, id) VALUES (?, NULL, ?)") - .bind(PUBLIC_API_TEST_IDS.app, DEPLOYMENT_ID) - .run(); - await database - .prepare( - "INSERT INTO app_deployment_run (app_id, deployment_id, id, plan_json, status) VALUES (?, ?, ?, ?, 'success')", - ) - .bind( - PUBLIC_API_TEST_IDS.app, - DEPLOYMENT_ID, - DEPLOYMENT_RUN_ID, - JSON.stringify({ agentBindings: [CLAIMS.binding] }), - ) - .run(); -} - -function revokeDeploymentWhenRunInsertStarts(database: SqliteD1Database): D1Database { - let revoked = false; - - function wrapStatement(statement: D1PreparedStatement, query: string): D1PreparedStatement { - const shouldRevoke = /\bINSERT\s+INTO\s+(?:"session_run"|session_run)(?:\s|\()/iu.test(query); - - return new Proxy(statement, { - get(target, property, receiver) { - if (property === "bind") { - return (...values: unknown[]) => wrapStatement(target.bind(...values), query); - } - - if ( - shouldRevoke && - !revoked && - (property === "all" || property === "first" || property === "raw" || property === "run") - ) { - const method = Reflect.get(target, property, receiver); - - if (typeof method === "function") { - return async (...args: unknown[]) => { - revoked = true; - await database - .prepare("UPDATE app_deployment SET deleted_at = ? WHERE id = ?") - .bind(Date.now(), DEPLOYMENT_ID) - .run(); - return method.apply(target, args); - }; - } - } - - return Reflect.get(target, property, receiver); - }, - }); - } - - return { - batch: database.batch.bind(database), - prepare: (query) => wrapStatement(database.prepare(query), query), - } as D1Database; -} - -function queueBoundRun(input: { bindings: ApiBindings; viewer: AuthenticatedViewer }) { - return queueSessionRun({ - bindings: input.bindings, - executionContext: null, - input: { - accessViewer: input.viewer, - attachmentIds: [], - boundCapabilityProvenance: { - agentId: CLAIMS.agentId, - appId: CLAIMS.appId, - bindingEnv: CLAIMS.binding.env, - bindingName: CLAIMS.binding.name, - deploymentId: CLAIMS.deploymentId, - deploymentRunId: CLAIMS.deploymentRunId, - }, - clientRequestId: null, - prompt: "Race the deployment deletion.", - runCreationGuard: createDeploymentAgentCapabilityRunCreationGuard(CLAIMS), - session: { - agent_id: CLAIMS.agentId, - app_id: CLAIMS.appId, - deployment_version_id: parsePlatformId( - PUBLIC_API_TEST_IDS.deployment, - "fixture deployment version", - ), - deployment_version_number: 1, - id: parsePlatformId(PUBLIC_API_TEST_IDS.ownerSession, "fixture session"), - model: "gpt-5.4", - provider: "openai", - runtime_id: "openai-runtime", - }, - }, - requestUrl: "https://api.example.com/api/v1/bound/test", - viewer: input.viewer, - }); -} - -function createBoundCapabilityAuditContext( - bindings: ApiBindings, - viewer: AuthenticatedViewer, -): GraphQLContext { - const executionCtx = createTestExecutionContext(); - - return { - bindings, - executionContext: executionCtx, - request: new Request("https://api.example.com/api/graphql"), - serverContext: { - ...bindings, - executionCtx, - }, - viewer, - }; -} - -describe("bound Agent Run revocation boundary", () => { - test("creates a Run while the claimed deployment authority remains current", async () => { - const database = await createPublicHttpContractDatabase(); - await insertOwnerSession(database); - await insertDeploymentAuthority(database); - const viewer = await getAccountViewer(database, PUBLIC_API_TEST_IDS.ownerAccount); - - if (viewer === null) { - throw new Error("Owner test viewer is missing."); - } - - const result = await queueBoundRun({ - bindings: createPublicHttpTestBindings(database) as ApiBindings, - viewer, - }); - - expect(result.run.status).toBe("queued"); - const response = await graphql({ - contextValue: createBoundCapabilityAuditContext( - createPublicHttpTestBindings(database) as ApiBindings, - viewer, - ), - schema: createGraphQLSchema(), - source: ` - query BoundCapabilityRunAudit($appId: ULID!, $runId: ULID!) { - boundCapabilityRunProvenance(appId: $appId, runId: $runId) { - agentId - appId - bindingEnv - bindingName - deploymentId - deploymentRunId - runId - } - } - `, - variableValues: { - appId: CLAIMS.appId, - runId: result.run.id, - }, - }); - - expect(response).toEqual({ - data: { - boundCapabilityRunProvenance: { - agentId: CLAIMS.agentId, - appId: CLAIMS.appId, - bindingEnv: CLAIMS.binding.env, - bindingName: CLAIMS.binding.name, - deploymentId: CLAIMS.deploymentId, - deploymentRunId: CLAIMS.deploymentRunId, - runId: result.run.id, - }, - }, - }); - await expect( - database - .prepare( - `SELECT - bound_capability_agent_id, - bound_capability_app_id, - bound_capability_binding_env, - bound_capability_binding_name, - bound_capability_deployment_id, - bound_capability_deployment_run_id - FROM session_run`, - ) - .first(), - ).resolves.toEqual({ - bound_capability_agent_id: CLAIMS.agentId, - bound_capability_app_id: CLAIMS.appId, - bound_capability_binding_env: CLAIMS.binding.env, - bound_capability_binding_name: CLAIMS.binding.name, - bound_capability_deployment_id: CLAIMS.deploymentId, - bound_capability_deployment_run_id: CLAIMS.deploymentRunId, - }); - await expect( - database.prepare("SELECT COUNT(*) AS count FROM session_run").first<{ count: number }>(), - ).resolves.toEqual({ count: 1 }); - }); - - test("does not expose accepted Run provenance to a non-owner", async () => { - const database = await createPublicHttpContractDatabase(); - await insertOwnerSession(database); - await insertDeploymentAuthority(database); - const owner = await getAccountViewer(database, PUBLIC_API_TEST_IDS.ownerAccount); - const nonOwner = await getAccountViewer(database, PUBLIC_API_TEST_IDS.nonOwnerAccount); - - if (owner === null || nonOwner === null) { - throw new Error("Test viewers are missing."); - } - - const result = await queueBoundRun({ - bindings: createPublicHttpTestBindings(database) as ApiBindings, - viewer: owner, - }); - - const response = await graphql({ - contextValue: createBoundCapabilityAuditContext( - createPublicHttpTestBindings(database) as ApiBindings, - nonOwner, - ), - schema: createGraphQLSchema(), - source: ` - query BoundCapabilityRunAudit($appId: ULID!, $runId: ULID!) { - boundCapabilityRunProvenance(appId: $appId, runId: $runId) { - runId - } - } - `, - variableValues: { - appId: CLAIMS.appId, - runId: result.run.id, - }, - }); - - expect(response.data).toEqual({ boundCapabilityRunProvenance: null }); - expect(response.errors?.[0]?.extensions.code).toBe("FORBIDDEN"); - }); - - test("returns no provenance for an existing non-bound Run", async () => { - const database = await createPublicHttpContractDatabase(); - await insertOwnerSession(database); - const viewer = await getAccountViewer(database, PUBLIC_API_TEST_IDS.ownerAccount); - - if (viewer === null) { - throw new Error("Owner test viewer is missing."); - } - - const result = await queueSessionRun({ - bindings: createPublicHttpTestBindings(database) as ApiBindings, - executionContext: null, - input: { - accessViewer: viewer, - attachmentIds: [], - clientRequestId: null, - prompt: "Read a Run created before capability provenance.", - session: { - agent_id: CLAIMS.agentId, - app_id: CLAIMS.appId, - deployment_version_id: parsePlatformId( - PUBLIC_API_TEST_IDS.deployment, - "fixture deployment version", - ), - deployment_version_number: 1, - id: parsePlatformId(PUBLIC_API_TEST_IDS.ownerSession, "fixture session"), - model: "gpt-5.4", - provider: "openai", - runtime_id: "openai-runtime", - }, - }, - requestUrl: "https://api.example.com/api/graphql", - viewer, - }); - - const response = await graphql({ - contextValue: createBoundCapabilityAuditContext( - createPublicHttpTestBindings(database) as ApiBindings, - viewer, - ), - schema: createGraphQLSchema(), - source: ` - query BoundCapabilityRunAudit($appId: ULID!, $runId: ULID!) { - boundCapabilityRunProvenance(appId: $appId, runId: $runId) { - runId - } - } - `, - variableValues: { - appId: CLAIMS.appId, - runId: result.run.id, - }, - }); - - expect(response).toEqual({ - data: { - boundCapabilityRunProvenance: null, - }, - }); - }); - - test("does not insert a Run when deletion commits after preflight authorization", async () => { - const database = await createPublicHttpContractDatabase(); - await insertOwnerSession(database); - await insertDeploymentAuthority(database); - const viewer = await getAccountViewer(database, PUBLIC_API_TEST_IDS.ownerAccount); - - if (viewer === null) { - throw new Error("Owner test viewer is missing."); - } - - const bindings = createPublicHttpTestBindings( - revokeDeploymentWhenRunInsertStarts(database), - ) as ApiBindings; - - await expect(queueBoundRun({ bindings, viewer })).rejects.toBeInstanceOf( - SessionRunCreationGuardRejectedError, - ); - - await expect( - database.prepare("SELECT COUNT(*) AS count FROM session_run").first<{ count: number }>(), - ).resolves.toEqual({ count: 0 }); - }); -}); diff --git a/apps/api/tests/app-agent-capability-revocation-http.test.ts b/apps/api/tests/app-agent-capability-revocation-http.test.ts deleted file mode 100644 index 5e969a43..00000000 --- a/apps/api/tests/app-agent-capability-revocation-http.test.ts +++ /dev/null @@ -1,262 +0,0 @@ -import { describe, expect, test } from "bun:test"; - -import { Hono } from "hono"; - -import { registerPublicApiRoute } from "../src/adapters/http/routes/public-api-route"; -import { mintAppAgentCapabilityToken } from "../src/modules/public-api/app-agent-capability"; -import type { AppAgentCapabilityClaims } from "../src/modules/public-api/app-agent-capability"; -import type { ApiBindings, ApiGatewayEnvironment } from "../src/platform/cloudflare/worker-types"; -import { - PUBLIC_API_TEST_IDS, - createPublicHttpContractDatabase, - createPublicHttpTestBindings, - createTestExecutionContext, -} from "./helpers/public-api-http-test-fixture"; -import type { SqliteD1Database } from "./helpers/public-api-http-test-fixture"; - -const DEPLOYMENT_ID = "01J0000000000000000000000D"; -const DEPLOYMENT_RUN_ID = "01J0000000000000000000000R"; - -function createBoundAgentRouteTestApp(): Hono { - const app = new Hono(); - const publicApi = new Hono(); - - registerPublicApiRoute(publicApi); - app.route("/api", publicApi); - return app; -} - -function capabilityClaims( - overrides: Partial = {}, -): AppAgentCapabilityClaims { - return { - agentId: PUBLIC_API_TEST_IDS.agent, - appId: PUBLIC_API_TEST_IDS.app, - binding: { - env: "MOSOO_PUBLIC_AGENT", - expose: "public_thread", - name: "Public API Agent", - }, - deploymentId: DEPLOYMENT_ID, - deploymentRunId: DEPLOYMENT_RUN_ID, - exp: Date.now() + 60_000, - ...overrides, - }; -} - -async function insertDeploymentAuthority( - database: SqliteD1Database, - input: { agentBindings: unknown[]; deletedAt: number | null }, -): Promise { - database.execute(` - CREATE TABLE app_deployment ( - app_id text NOT NULL, - deleted_at integer, - id text PRIMARY KEY NOT NULL - ); - - CREATE TABLE app_deployment_run ( - app_id text NOT NULL, - deployment_id text NOT NULL, - id text PRIMARY KEY NOT NULL, - plan_json text, - status text NOT NULL - ); - - CREATE INDEX app_deployment_run_deployment_id_idx - ON app_deployment_run (deployment_id, id); - `); - - await database - .prepare("INSERT INTO app_deployment (app_id, deleted_at, id) VALUES (?, ?, ?)") - .bind(PUBLIC_API_TEST_IDS.app, input.deletedAt, DEPLOYMENT_ID) - .run(); - await database - .prepare( - "INSERT INTO app_deployment_run (app_id, deployment_id, id, plan_json, status) VALUES (?, ?, ?, ?, ?)", - ) - .bind( - PUBLIC_API_TEST_IDS.app, - DEPLOYMENT_ID, - DEPLOYMENT_RUN_ID, - JSON.stringify({ agentBindings: input.agentBindings }), - "success", - ) - .run(); -} - -async function requestBoundAgent( - database: D1Database, - claims: AppAgentCapabilityClaims, -): Promise { - const bindings = createPublicHttpTestBindings(database) as ApiBindings; - const token = await mintAppAgentCapabilityToken(bindings.RUNTIME_ACTION_TOKEN_SECRET, claims); - - return createBoundAgentRouteTestApp().request( - new Request(`https://api.example.com/api/v1/bound/${token}`, { - body: JSON.stringify({ message: "Hello" }), - method: "POST", - }), - undefined, - bindings, - createTestExecutionContext(), - ); -} - -async function withProviderProbeMock(operation: () => Promise): Promise { - const originalFetch = globalThis.fetch; - globalThis.fetch = async () => - Response.json({ - data: [{ id: "gpt-5.4" }], - }); - - try { - return await operation(); - } finally { - globalThis.fetch = originalFetch; - } -} - -function revokeDeploymentWhenRunInsertStarts(database: SqliteD1Database): D1Database { - let revoked = false; - - function wrapStatement(statement: D1PreparedStatement, query: string): D1PreparedStatement { - const shouldRevoke = /\bINSERT\s+INTO\s+(?:"session_run"|session_run)(?:\s|\()/iu.test(query); - - return new Proxy(statement, { - get(target, property, receiver) { - if (property === "bind") { - return (...values: unknown[]) => wrapStatement(target.bind(...values), query); - } - - if ( - shouldRevoke && - !revoked && - (property === "all" || property === "first" || property === "raw" || property === "run") - ) { - const method = Reflect.get(target, property, receiver); - - if (typeof method === "function") { - return async (...args: unknown[]) => { - revoked = true; - await database - .prepare("UPDATE app_deployment SET deleted_at = ? WHERE id = ?") - .bind(Date.now(), DEPLOYMENT_ID) - .run(); - return method.apply(target, args); - }; - } - } - - return Reflect.get(target, property, receiver); - }, - }); - } - - return { - batch: database.batch.bind(database), - prepare: (query) => wrapStatement(database.prepare(query), query), - } as D1Database; -} - -async function expectNoSessions(database: SqliteD1Database): Promise { - await expect( - database.prepare("SELECT COUNT(*) AS count FROM session").first<{ count: number }>(), - ).resolves.toEqual({ count: 0 }); -} - -describe("bound Agent capability revocation HTTP boundary", () => { - test("rejects a deleted deployment capability before it can create a Session", async () => { - const database = await createPublicHttpContractDatabase(); - await insertDeploymentAuthority(database, { - agentBindings: [capabilityClaims().binding], - deletedAt: Date.now(), - }); - const response = await requestBoundAgent(database, capabilityClaims()); - - expect(response.status).toBe(409); - expect(await response.json()).toEqual({ - error: { - code: "agent_not_published", - message: "This capability is no longer authorized for the active deployment.", - }, - }); - await expectNoSessions(database); - }); - - test("rejects an expired capability before reading deployment state", async () => { - const database = await createPublicHttpContractDatabase(); - const response = await requestBoundAgent(database, capabilityClaims({ exp: Date.now() })); - - expect(response.status).toBe(401); - expect(await response.json()).toEqual({ - error: { - code: "unauthenticated", - message: "The capability URL is invalid or has expired.", - }, - }); - await expectNoSessions(database); - }); - - test("rejects an unpublished Agent before it can create a Session", async () => { - const database = await createPublicHttpContractDatabase(); - await database - .prepare("UPDATE agent SET status = 'draft' WHERE id = ?") - .bind(PUBLIC_API_TEST_IDS.agent) - .run(); - - const response = await requestBoundAgent(database, capabilityClaims()); - - expect(response.status).toBe(409); - expect(await response.json()).toEqual({ - error: { - code: "agent_not_published", - message: "This Agent is no longer published for bound calls.", - }, - }); - await expectNoSessions(database); - }); - - test("rejects a capability whose current successful revision removed its binding", async () => { - const database = await createPublicHttpContractDatabase(); - await insertDeploymentAuthority(database, { - agentBindings: [], - deletedAt: null, - }); - - const response = await requestBoundAgent(database, capabilityClaims()); - - expect(response.status).toBe(409); - expect(await response.json()).toEqual({ - error: { - code: "agent_not_published", - message: "This capability is no longer authorized for the active deployment.", - }, - }); - await expectNoSessions(database); - }); - - test("cleans up the new Session when deletion wins the final Run creation race", async () => { - const database = await createPublicHttpContractDatabase(); - await insertDeploymentAuthority(database, { - agentBindings: [capabilityClaims().binding], - deletedAt: null, - }); - - const response = await withProviderProbeMock(() => - requestBoundAgent(revokeDeploymentWhenRunInsertStarts(database), capabilityClaims()), - ); - - expect(response.status).toBe(409); - expect(await response.json()).toEqual({ - error: { - code: "agent_not_published", - message: "This capability is no longer authorized for the active deployment.", - }, - }); - await expectNoSessions(database); - await expect( - database.prepare("SELECT COUNT(*) AS count FROM session_run").first<{ count: number }>(), - ).resolves.toEqual({ count: 0 }); - }); -}); diff --git a/apps/api/tests/app-agent-capability.test.ts b/apps/api/tests/app-agent-capability.test.ts deleted file mode 100644 index 86d51276..00000000 --- a/apps/api/tests/app-agent-capability.test.ts +++ /dev/null @@ -1,103 +0,0 @@ -import { describe, expect, test } from "bun:test"; - -import { parsePlatformId } from "@mosoo/id"; -import type { AgentId, AppDeploymentId, AppDeploymentRunId, AppId } from "@mosoo/id"; - -import { - boundAgentUrl, - inspectAppAgentCapabilityToken, - mintAppAgentCapabilityToken, - verifyAppAgentCapabilityToken, -} from "../src/modules/public-api/app-agent-capability"; -import type { AppAgentCapabilityClaims } from "../src/modules/public-api/app-agent-capability"; - -const SECRET = "test-capability-secret"; -const NOW = 1_000_000; -const AGENT_ID = parsePlatformId("01J00000000000000000000009"); -const APP_ID = parsePlatformId("01J0000000000000000000000Q"); -const DEPLOYMENT_ID = parsePlatformId("01J0000000000000000000000D"); -const DEPLOYMENT_RUN_ID = parsePlatformId("01J0000000000000000000000R"); - -function claims(overrides: Partial = {}): AppAgentCapabilityClaims { - return { - agentId: AGENT_ID, - appId: APP_ID, - binding: { env: "MOSOO_AGENT", expose: "public_thread", name: "Roadmap" }, - deploymentId: DEPLOYMENT_ID, - deploymentRunId: DEPLOYMENT_RUN_ID, - exp: NOW + 60_000, - ...overrides, - }; -} - -describe("app agent capability token", () => { - test("round-trips mint and verify", async () => { - const token = await mintAppAgentCapabilityToken(SECRET, claims()); - expect(await verifyAppAgentCapabilityToken(SECRET, token, NOW)).toEqual(claims()); - }); - - test("rejects a token signed with a different secret", async () => { - const token = await mintAppAgentCapabilityToken(SECRET, claims()); - expect(await verifyAppAgentCapabilityToken("other-secret", token, NOW)).toBeNull(); - }); - - test("rejects a tampered payload", async () => { - const token = await mintAppAgentCapabilityToken(SECRET, claims()); - const tampered = `${token.split(".")[0]}x.${token.split(".")[1]}`; - expect(await verifyAppAgentCapabilityToken(SECRET, tampered, NOW)).toBeNull(); - }); - - test("rejects an expired token", async () => { - const token = await mintAppAgentCapabilityToken(SECRET, claims({ exp: NOW })); - expect(await verifyAppAgentCapabilityToken(SECRET, token, NOW)).toBeNull(); - await expect(inspectAppAgentCapabilityToken(SECRET, token, NOW)).resolves.toEqual({ - claims: claims({ exp: NOW }), - status: "expired", - }); - }); - - test("rejects a malformed token", async () => { - expect(await verifyAppAgentCapabilityToken(SECRET, "not-a-token", NOW)).toBeNull(); - expect(await verifyAppAgentCapabilityToken(SECRET, "", NOW)).toBeNull(); - }); - - test("rejects a legacy token without deployment authority claims", async () => { - const legacy = await mintLegacyToken(SECRET, { - agentId: AGENT_ID, - appId: APP_ID, - exp: NOW + 60_000, - expose: "public_thread", - }); - - expect(await verifyAppAgentCapabilityToken(SECRET, legacy, NOW)).toBeNull(); - }); - - test("builds a bound-agent url whose embedded token verifies", async () => { - const token = await mintAppAgentCapabilityToken(SECRET, claims()); - const url = boundAgentUrl("https://api.mosoo.ai/", token); - expect(url).toBe(`https://api.mosoo.ai/api/v1/bound/${token}`); - const embedded = url.slice(url.lastIndexOf("/") + 1); - expect(await verifyAppAgentCapabilityToken(SECRET, embedded, NOW)).toEqual(claims()); - }); -}); - -async function mintLegacyToken(secret: string, payload: Record): Promise { - const encoded = btoa(JSON.stringify(payload)) - .replaceAll("+", "-") - .replaceAll("/", "_") - .replaceAll("=", ""); - const key = await crypto.subtle.importKey( - "raw", - new TextEncoder().encode(secret), - { hash: "SHA-256", name: "HMAC" }, - false, - ["sign"], - ); - const signature = await crypto.subtle.sign("HMAC", key, new TextEncoder().encode(encoded)); - const signatureEncoded = btoa(String.fromCharCode(...new Uint8Array(signature))) - .replaceAll("+", "-") - .replaceAll("/", "_") - .replaceAll("=", ""); - - return `${encoded}.${signatureEncoded}`; -} diff --git a/apps/api/tests/app-deployment-capability-authority.test.ts b/apps/api/tests/app-deployment-capability-authority.test.ts deleted file mode 100644 index 56425ee1..00000000 --- a/apps/api/tests/app-deployment-capability-authority.test.ts +++ /dev/null @@ -1,162 +0,0 @@ -import { describe, expect, test } from "bun:test"; - -import { createPlatformId } from "@mosoo/id"; -import type { AppDeploymentId, AppDeploymentRunId, AppId } from "@mosoo/id"; - -import { getDeploymentAgentCapabilityAuthority } from "../src/modules/apps/application/app-deployment-capability-authority.service"; -import { SqliteD1Database } from "./helpers/sqlite-d1"; - -const APP_ID = createPlatformId(1); -const DEPLOYMENT_ID = createPlatformId(1); -const SUCCESSFUL_RUN_ID = createPlatformId(1); -const FAILED_RUN_ID = createPlatformId(1); -const REPLACEMENT_RUN_ID = createPlatformId(1); - -const BINDING = { env: "MOSOO_AGENT", expose: "public_thread" as const, name: "Support" }; - -function createDatabase(): SqliteD1Database { - const database = new SqliteD1Database({ foreignKeys: false }); - - database.execute(` - CREATE TABLE app_deployment ( - app_id text NOT NULL, - deleted_at integer, - id text PRIMARY KEY NOT NULL - ); - - CREATE TABLE app_deployment_run ( - app_id text NOT NULL, - deployment_id text NOT NULL, - id text PRIMARY KEY NOT NULL, - plan_json text, - status text NOT NULL - ); - - CREATE INDEX app_deployment_run_deployment_id_idx - ON app_deployment_run (deployment_id, id); - `); - - return database; -} - -function plan(bindings: readonly (typeof BINDING)[]): string { - return JSON.stringify({ agentBindings: bindings }); -} - -async function insertDeployment(database: SqliteD1Database, deletedAt: number | null = null) { - await database - .prepare("INSERT INTO app_deployment (app_id, deleted_at, id) VALUES (?, ?, ?)") - .bind(APP_ID, deletedAt, DEPLOYMENT_ID) - .run(); -} - -async function insertRun(input: { - database: SqliteD1Database; - id: AppDeploymentRunId; - planJson: string | null; - status: "failed" | "success"; -}) { - await input.database - .prepare( - "INSERT INTO app_deployment_run (app_id, deployment_id, id, plan_json, status) VALUES (?, ?, ?, ?, ?)", - ) - .bind(APP_ID, DEPLOYMENT_ID, input.id, input.planJson, input.status) - .run(); -} - -function authority() { - return { - appId: APP_ID, - binding: BINDING, - deploymentId: DEPLOYMENT_ID, - deploymentRunId: SUCCESSFUL_RUN_ID, - }; -} - -describe("deployment bound-agent capability authority", () => { - test("accepts the current successful deployment binding", async () => { - const database = createDatabase(); - await insertDeployment(database); - await insertRun({ - database, - id: SUCCESSFUL_RUN_ID, - planJson: plan([BINDING]), - status: "success", - }); - - await expect(getDeploymentAgentCapabilityAuthority(database, authority())).resolves.toEqual({ - authorized: true, - }); - }); - - test("rejects a capability after its deployment is deleted", async () => { - const database = createDatabase(); - await insertDeployment(database, 1); - await insertRun({ - database, - id: SUCCESSFUL_RUN_ID, - planJson: plan([BINDING]), - status: "success", - }); - - await expect(getDeploymentAgentCapabilityAuthority(database, authority())).resolves.toEqual({ - authorized: false, - reason: "deployment_deleted", - }); - }); - - test("keeps the prior capability valid when a newer deployment run fails", async () => { - const database = createDatabase(); - await insertDeployment(database); - await insertRun({ - database, - id: SUCCESSFUL_RUN_ID, - planJson: plan([BINDING]), - status: "success", - }); - await insertRun({ database, id: FAILED_RUN_ID, planJson: plan([]), status: "failed" }); - - await expect(getDeploymentAgentCapabilityAuthority(database, authority())).resolves.toEqual({ - authorized: true, - }); - }); - - test("rejects a capability after a successful revision removes its binding", async () => { - const database = createDatabase(); - await insertDeployment(database); - await insertRun({ - database, - id: SUCCESSFUL_RUN_ID, - planJson: plan([BINDING]), - status: "success", - }); - await insertRun({ database, id: REPLACEMENT_RUN_ID, planJson: plan([]), status: "success" }); - - await expect(getDeploymentAgentCapabilityAuthority(database, authority())).resolves.toEqual({ - authorized: false, - reason: "binding_removed", - }); - }); - - test("reports a superseded successful revision even when it retains the binding", async () => { - const database = createDatabase(); - await insertDeployment(database); - await insertRun({ - database, - id: SUCCESSFUL_RUN_ID, - planJson: plan([BINDING]), - status: "success", - }); - await insertRun({ - database, - id: REPLACEMENT_RUN_ID, - planJson: plan([BINDING]), - status: "success", - }); - - await expect(getDeploymentAgentCapabilityAuthority(database, authority())).resolves.toEqual({ - authorized: false, - reason: "deployment_revision_replaced", - }); - }); -}); diff --git a/apps/api/tests/app-deployment-cloudflare-client.test.ts b/apps/api/tests/app-deployment-cloudflare-client.test.ts deleted file mode 100644 index 9721ee3b..00000000 --- a/apps/api/tests/app-deployment-cloudflare-client.test.ts +++ /dev/null @@ -1,38 +0,0 @@ -import { describe, expect, test } from "bun:test"; - -import { createWorkerModuleUpload } from "../src/modules/apps/application/app-deployment-cloudflare-client"; - -describe("app deployment Cloudflare client", () => { - test("uses the module name and metadata as multipart part names", async () => { - const scriptContent = "export default { fetch() {} };"; - const upload = createWorkerModuleUpload({ - compatibilityDate: "2026-07-14", - mainModuleName: "worker.js", - scriptContent, - scriptName: "example", - vars: { MOSOO_AGENT_URL: "https://example.com/bound/token" }, - }); - - const modulePart = upload.get("worker.js"); - - expect(upload).toBeInstanceOf(FormData); - expect(modulePart).toBeInstanceOf(File); - expect((modulePart as File).name).toBe("worker.js"); - expect((modulePart as File).type).toBe("application/javascript+module"); - expect(await (modulePart as File).text()).toBe(scriptContent); - expect(upload.get("files")).toBeNull(); - expect(upload.get("metadata")).toBe( - JSON.stringify({ - bindings: [ - { - name: "MOSOO_AGENT_URL", - text: "https://example.com/bound/token", - type: "plain_text", - }, - ], - compatibility_date: "2026-07-14", - main_module: "worker.js", - }), - ); - }); -}); diff --git a/apps/api/tests/app-deployment-detector.test.ts b/apps/api/tests/app-deployment-detector.test.ts deleted file mode 100644 index e5b4c784..00000000 --- a/apps/api/tests/app-deployment-detector.test.ts +++ /dev/null @@ -1,404 +0,0 @@ -import { describe, expect, test } from "bun:test"; - -import { - AppDeploymentDetectionError, - detectAppDeploymentPlan, -} from "../src/modules/apps/application/app-deployment-detector"; - -const RESOURCE_NAME = "app-01j00000000000000000000054"; - -function detect(files: Record) { - return detectAppDeploymentPlan({ files }, { resourceName: RESOURCE_NAME }); -} - -describe("app deployment detector", () => { - test("detects a root static page without install or build", () => { - expect(detect({ "index.html": "
Hello
" })).toMatchObject({ - buildCommand: null, - installCommand: null, - outputDir: ".", - packageManager: "none", - rootDir: ".", - targetKind: "cloudflare_pages", - targetMode: "static_assets", - }); - }); - - test("uses the caller-provided Cloudflare resource name", () => { - expect(detect({ "index.html": "
Hello
" }).generatedWranglerConfig).toContain( - `name = "${RESOURCE_NAME}"`, - ); - }); - - test("detects Vite static output", () => { - expect( - detect({ - "package.json": JSON.stringify({ - devDependencies: { vite: "^7.0.0" }, - scripts: { build: "vite build" }, - }), - "pnpm-lock.yaml": "", - }), - ).toMatchObject({ - buildCommand: "pnpm run build", - installCommand: "pnpm install --frozen-lockfile", - outputDir: "dist", - packageManager: "pnpm", - targetKind: "cloudflare_pages", - }); - }); - - test("requires a build script for Vite static output", () => { - expect(() => - detect({ - "index.html": "
", - "package.json": JSON.stringify({ - devDependencies: { vite: "^7.0.0" }, - }), - "pnpm-lock.yaml": "", - }), - ).toThrow(AppDeploymentDetectionError); - }); - - test("does not freeze install when packageManager has no lockfile", () => { - expect( - detect({ - "package.json": JSON.stringify({ - devDependencies: { vite: "^7.0.0" }, - packageManager: "pnpm@10.0.0", - scripts: { build: "vite build" }, - }), - }), - ).toMatchObject({ - installCommand: "pnpm install", - packageManager: "pnpm", - }); - }); - - test("requires explicit static export for Next.js", () => { - expect(() => - detect({ - "package.json": JSON.stringify({ - dependencies: { next: "^16.0.0" }, - scripts: { build: "next build" }, - }), - }), - ).toThrow(AppDeploymentDetectionError); - }); - - test("detects Next.js static export", () => { - expect( - detect({ - "next.config.mjs": "export default { output: 'export' };", - "package-lock.json": "{}", - "package.json": JSON.stringify({ - dependencies: { next: "^16.0.0" }, - scripts: { build: "next build" }, - }), - }), - ).toMatchObject({ - buildCommand: "npm run build", - installCommand: "npm ci", - outputDir: "out", - packageManager: "npm", - targetKind: "cloudflare_pages", - }); - }); - - test("uses .mosoo.toml static override", () => { - expect( - detect({ - ".mosoo.toml": ` -type = "static" -root = "site" - -[build] -install = "bun install --frozen-lockfile" -command = "bun run build" -output = "public" - -[routes] -fallback = "index.html" -`, - "site/package.json": JSON.stringify({ scripts: { build: "vite build" } }), - }), - ).toMatchObject({ - buildCommand: "bun run build", - installCommand: "bun install --frozen-lockfile", - mosooConfigPath: ".mosoo.toml", - outputDir: "public", - routesFallback: "index.html", - rootDir: "site", - targetKind: "cloudflare_pages", - }); - }); - - test("uses .mosoo.toml worker override", () => { - expect( - detect({ - ".mosoo.toml": ` -type = "worker" - -[worker] -entry = "src/index.js" -`, - }), - ).toMatchObject({ - mosooConfigPath: ".mosoo.toml", - outputDir: null, - rootDir: ".", - targetKind: "cloudflare_worker", - targetMode: "worker_module", - }); - }); - - test("keeps the legacy flat worker override taking precedence over wrangler main", () => { - expect( - detect({ - ".mosoo.toml": ` -type = "worker" - -[worker] -entry = "src/index.js" -`, - "wrangler.toml": 'main = "src/other.js"\n', - }), - ).toMatchObject({ - mosooConfigPath: ".mosoo.toml", - targetKind: "cloudflare_worker", - targetMode: "worker_module", - workerEntry: "src/index.js", - }); - }); - - test("parses .mosoo.toml [[agents]] bindings", () => { - expect( - detect({ - ".mosoo.toml": ` -type = "worker" - -[worker] -entry = "src/index.js" - -[[agents]] -name = "roadmap" -expose = "public_thread" -env = "ROADMAP_THREAD_URL" - -[[agents]] -name = "triage" -expose = "public_thread" -env = "TRIAGE_THREAD_URL" -`, - }).agentBindings, - ).toEqual([ - { env: "ROADMAP_THREAD_URL", expose: "public_thread", name: "roadmap" }, - { env: "TRIAGE_THREAD_URL", expose: "public_thread", name: "triage" }, - ]); - }); - - test("parses the schema-v1 product manifest into a worker target", () => { - const plan = detect({ - ".mosoo.toml": ` -schema = 1 -name = "roadmap-board" - -[deploy] -adapter = "cloudflare-workers" -wrangler = "wrangler.toml" - -[[agents]] -name = "roadmap" -expose = "public_thread" -env = "MOSOO_AGENT_ROADMAP_URL" -`, - "wrangler.toml": 'name = "roadmap-board"\nmain = "src/index.js"\n', - }); - - expect(plan).toMatchObject({ - mosooConfigPath: ".mosoo.toml", - outputDir: null, - rootDir: ".", - targetKind: "cloudflare_worker", - targetMode: "worker_module", - workerEntry: "src/index.js", - }); - expect(plan.agentBindings).toEqual([ - { env: "MOSOO_AGENT_ROADMAP_URL", expose: "public_thread", name: "roadmap" }, - ]); - }); - - test("rejects duplicate agent names", () => { - expect(() => - detect({ - ".mosoo.toml": ` -schema = 1 - -[deploy] -adapter = "cloudflare-workers" -wrangler = "wrangler.toml" - -[[agents]] -name = "roadmap" -expose = "public_thread" -env = "ROADMAP_THREAD_URL" - -[[agents]] -name = "roadmap" -expose = "public_thread" -env = "TRIAGE_THREAD_URL" -`, - "wrangler.toml": 'main = "src/index.js"\n', - }), - ).toThrow(AppDeploymentDetectionError); - }); - - test("rejects duplicate agent env vars", () => { - expect(() => - detect({ - ".mosoo.toml": ` -schema = 1 - -[deploy] -adapter = "cloudflare-workers" -wrangler = "wrangler.toml" - -[[agents]] -name = "roadmap" -expose = "public_thread" -env = "SHARED_THREAD_URL" - -[[agents]] -name = "triage" -expose = "public_thread" -env = "SHARED_THREAD_URL" -`, - "wrangler.toml": 'main = "src/index.js"\n', - }), - ).toThrow(AppDeploymentDetectionError); - }); - - test("rejects an agent binding that is not public_thread", () => { - expect(() => - detect({ - ".mosoo.toml": ` -type = "worker" - -[worker] -entry = "src/index.js" - -[[agents]] -name = "roadmap" -expose = "private" -env = "ROADMAP_THREAD_URL" -`, - }), - ).toThrow(AppDeploymentDetectionError); - }); - - test("rejects [[agents]] on a static deployment", () => { - expect(() => - detect({ - ".mosoo.toml": ` -type = "static" - -[build] -output = "dist" - -[[agents]] -name = "roadmap" -expose = "public_thread" -env = "ROADMAP_THREAD_URL" -`, - }), - ).toThrow(AppDeploymentDetectionError); - }); - - test("repository-shape detection yields no agent bindings", () => { - expect(detect({ "index.html": "
Hello
" }).agentBindings).toEqual([]); - }); - - test("rejects TypeScript worker entry in the first cut", () => { - expect(() => - detect({ - ".mosoo.toml": ` -type = "worker" - -[worker] -entry = "src/index.ts" -`, - }), - ).toThrow(AppDeploymentDetectionError); - }); - - test("rejects routes fallback for worker override", () => { - expect(() => - detect({ - ".mosoo.toml": ` -type = "worker" - -[worker] -entry = "src/index.ts" - -[routes] -fallback = "index.html" -`, - }), - ).toThrow(AppDeploymentDetectionError); - }); - - test("detects wrangler main as a Worker hint", () => { - expect( - detect({ - "package.json": JSON.stringify({ - dependencies: { hono: "^4.0.0" }, - scripts: { build: "tsc" }, - }), - "wrangler.jsonc": '{ "main": "src/index.js" }', - }), - ).toMatchObject({ - buildCommand: "npm run build", - installCommand: "npm install", - packageManager: "npm", - targetKind: "cloudflare_worker", - targetMode: "worker_module", - }); - }); - - test("continues reading Wrangler hints until it finds main", () => { - expect( - detect({ - "package.json": JSON.stringify({ scripts: { build: "tsc" } }), - "wrangler.jsonc": '{ "main": "src/index.js" }', - "wrangler.toml": "name = ", - }), - ).toMatchObject({ - targetKind: "cloudflare_worker", - }); - }); - - test("rejects unsupported .mosoo.toml fields", () => { - expect(() => - detect({ - ".mosoo.toml": ` -type = "static" -account_id = "do-not-pass-through" -`, - }), - ).toThrow(AppDeploymentDetectionError); - }); - - test("rejects .mosoo.toml paths outside the repository", () => { - expect(() => - detect({ - ".mosoo.toml": ` -type = "worker" -root = "apps/../secret" - -[worker] -entry = "src/index.ts" -`, - }), - ).toThrow(AppDeploymentDetectionError); - }); -}); diff --git a/apps/api/tests/app-deployment-service.test.ts b/apps/api/tests/app-deployment-service.test.ts deleted file mode 100644 index 57c8e284..00000000 --- a/apps/api/tests/app-deployment-service.test.ts +++ /dev/null @@ -1,1663 +0,0 @@ -import { describe, expect, test } from "bun:test"; - -import { apiCommandsTable, appDeploymentRunsTable, appDeploymentsTable } from "@mosoo/db"; -import { eq } from "drizzle-orm"; - -import { createAppDeploymentRunDispatchDedupeKey } from "../src/modules/api-command/application/api-command-enqueue"; -import { API_COMMAND_QUEUE_SEND_FAILED_CODE } from "../src/modules/api-command/application/api-command-ledger"; -import { - APP_DEPLOYMENT_RUN_DISPATCH_MAX_ATTEMPTS, - APP_DEPLOYMENT_RUN_DISPATCH_RETRY_EXHAUSTED_CODE, -} from "../src/modules/api-command/application/api-command-policy"; -import { processApiCommandDeadLetterMessage } from "../src/modules/api-command/application/api-command-processor"; -import { getDeploymentAgentCapabilityAuthority } from "../src/modules/apps/application/app-deployment-capability-authority.service"; -import type { CloudflareDeploymentClient } from "../src/modules/apps/application/app-deployment-cloudflare-client"; -import type { AppDeploymentBuildRunner } from "../src/modules/apps/application/app-deployment-executor.service"; -import { dispatchAppDeploymentRun } from "../src/modules/apps/application/app-deployment-executor.service"; -import { - deleteAppDeployment, - deployApp, - getAppDeployment, - getAppDeploymentStatus, - listAppDeploymentRuns, -} from "../src/modules/apps/application/app-deployment.service"; -import type { AuthenticatedViewer } from "../src/modules/auth/application/viewer-auth.service"; -import type { SandboxHandle } from "../src/modules/runtime/infrastructure/sandbox-handles"; -import { createApiWorker } from "../src/platform/cloudflare/create-api-worker"; -import type { ApiBindings } from "../src/platform/cloudflare/worker-types"; -import { API_ERROR_CODE } from "../src/platform/errors"; -import type { ApiError } from "../src/platform/errors"; -import { currentTimestampMs } from "../src/time"; -import { - createApiCommandQueueStub, - createRecordedQueueMessage, -} from "./helpers/api-command-queue-fixture"; -import { SqliteD1Database } from "./helpers/sqlite-d1"; - -const OWNER_ID = "01J00000000000000000000001"; -const APP_ID = "01J0000000000000000000000Q"; -const OTHER_APP_ID = "01J0000000000000000000000R"; -const DEPLOYMENT_ID = "01J0000000000000000000000D"; -const OTHER_DEPLOYMENT_ID = "01J0000000000000000000000E"; -const NOW_MS = Date.parse("2026-06-26T00:00:00.000Z"); - -const VIEWER: AuthenticatedViewer = { - email: "owner@example.com", - emailVerified: true, - id: OWNER_ID, - imageUrl: null, - name: "Owner", -}; - -function createDatabase(): SqliteD1Database { - const database = new SqliteD1Database({ foreignKeys: false }); - - database.execute(` - CREATE TABLE app ( - id text PRIMARY KEY NOT NULL, - organization_id text NOT NULL, - owner_account_id text NOT NULL, - name text NOT NULL, - default_environment_id text, - created_at integer NOT NULL, - updated_at integer NOT NULL - ); - - CREATE TABLE app_deployment ( - app_id text NOT NULL, - created_at integer NOT NULL, - default_branch text NOT NULL, - deleted_at integer, - id text PRIMARY KEY NOT NULL, - last_successful_url text, - latest_run_id text, - mosoo_subdomain text NOT NULL, - owner_account_id text NOT NULL, - repo_name text NOT NULL, - repo_owner text NOT NULL, - repo_url text NOT NULL, - source_kind text NOT NULL, - updated_at integer NOT NULL - ); - - CREATE UNIQUE INDEX app_deployment_active_app_idx - ON app_deployment (app_id) - WHERE deleted_at IS NULL; - - CREATE TABLE app_deployment_run ( - app_id text NOT NULL, - created_at integer NOT NULL, - deployment_id text NOT NULL, - error_code text, - error_message text, - external_deployment_id text, - external_project_id text, - external_version_id text, - generated_wrangler_config_json text, - id text PRIMARY KEY NOT NULL, - mosoo_config_json text, - plan_json text, - source_branch text NOT NULL, - source_commit_sha text NOT NULL, - status text NOT NULL, - target_kind text, - target_project_name text, - target_script_name text, - updated_at integer NOT NULL, - url text - ); - - CREATE UNIQUE INDEX app_deployment_run_active_app_idx - ON app_deployment_run (app_id) - WHERE status IN ('queued', 'preparing', 'building', 'submitting', 'submitted', 'activating'); - - CREATE TABLE api_command ( - attempt_count integer DEFAULT 0 NOT NULL, - claim_expires_at integer, - claim_owner text, - completed_at integer, - created_at integer NOT NULL, - dedupe_key text NOT NULL, - id text PRIMARY KEY NOT NULL, - kind text NOT NULL, - last_error_code text, - last_error_message text, - payload_json text NOT NULL, - status text NOT NULL, - updated_at integer NOT NULL - ); - - CREATE UNIQUE INDEX api_command_dedupe_idx ON api_command (dedupe_key); - - INSERT INTO app ( - id, - organization_id, - owner_account_id, - name, - created_at, - updated_at - ) - VALUES ('${APP_ID}', '01J00000000000000000000006', '${OWNER_ID}', 'App', 1, 1); - `); - - return database; -} - -function createBindings(database: SqliteD1Database) { - const queue = createApiCommandQueueStub(); - - return { - bindings: { - API_COMMAND_QUEUE: queue, - CLOUDFLARE_ACCOUNT_ID: "test-account", - CLOUDFLARE_API_TOKEN: "test-token", - CLOUDFLARE_ZONE_ID: "test-zone", - DB: database, - MOSOO_APP_DEPLOYMENT_DOMAIN: "apps.localhost", - } as Pick< - ApiBindings, - | "API_COMMAND_QUEUE" - | "CLOUDFLARE_ACCOUNT_ID" - | "CLOUDFLARE_API_TOKEN" - | "CLOUDFLARE_ZONE_ID" - | "DB" - | "MOSOO_APP_DEPLOYMENT_DOMAIN" - >, - queue, - }; -} - -const githubFetch: typeof fetch = async (input) => { - const url = input instanceof URL ? input.href : typeof input === "string" ? input : input.url; - - if (url === "https://api.github.com/repos/samzong/awire") { - return Response.json({ - clone_url: "https://github.com/samzong/awire.git", - default_branch: "main", - name: "awire", - owner: { login: "samzong" }, - private: false, - }); - } - - if (url === "https://api.github.com/repos/samzong/awire/branches/main") { - return Response.json({ - commit: { sha: "abc123" }, - }); - } - - return new Response("not found", { status: 404 }); -}; - -async function setDeploymentRunUpdatedAt( - database: SqliteD1Database, - runId: string, - updatedAt: number, -): Promise { - await database - .prepare("UPDATE app_deployment_run SET updated_at = ? WHERE id = ?") - .bind(updatedAt, runId) - .run(); -} - -async function seedExpiredRunningDispatch( - database: SqliteD1Database, - runId: string, -): Promise { - await database - .prepare( - "UPDATE api_command SET status = 'running', claim_owner = 'stale-owner', claim_expires_at = 1 WHERE dedupe_key = ?", - ) - .bind(createAppDeploymentRunDispatchDedupeKey(runId)) - .run(); - await setDeploymentRunUpdatedAt(database, runId, 1); -} - -async function seedExhaustedRunningDispatch( - database: SqliteD1Database, - runId: string, -): Promise { - await database - .prepare( - `UPDATE api_command - SET status = 'running', - claim_owner = 'worker-owner', - claim_expires_at = ?, - attempt_count = ?, - last_error_code = 'SandboxError', - last_error_message = 'Container is starting. Please retry in a moment.' - WHERE dedupe_key = ?`, - ) - .bind( - NOW_MS + 60_000, - APP_DEPLOYMENT_RUN_DISPATCH_MAX_ATTEMPTS, - createAppDeploymentRunDispatchDedupeKey(runId), - ) - .run(); -} - -async function seedQueuedDispatch(database: SqliteD1Database, runId: string): Promise { - await database - .prepare( - `INSERT INTO api_command ( - attempt_count, - claim_expires_at, - claim_owner, - completed_at, - created_at, - dedupe_key, - id, - kind, - last_error_code, - last_error_message, - payload_json, - status, - updated_at - ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`, - ) - .bind( - 0, - null, - null, - null, - NOW_MS, - createAppDeploymentRunDispatchDedupeKey(runId), - `cmd-${runId}`, - "app_deployment_run_dispatch", - null, - null, - JSON.stringify({ appDeploymentRunId: runId }), - "queued", - NOW_MS, - ) - .run(); -} - -async function seedDeployment( - database: SqliteD1Database, - input: { appId: string; deploymentId: string }, -): Promise { - await database - .prepare( - `INSERT INTO app_deployment ( - app_id, created_at, default_branch, deleted_at, id, last_successful_url, - latest_run_id, mosoo_subdomain, owner_account_id, repo_name, repo_owner, - repo_url, source_kind, updated_at - ) - VALUES (?, ?, 'main', NULL, ?, NULL, NULL, ?, ?, 'awire', 'samzong', - 'https://github.com/samzong/awire.git', 'github_public', ?)`, - ) - .bind( - input.appId, - NOW_MS, - input.deploymentId, - `app-${input.appId.toLowerCase()}`, - OWNER_ID, - NOW_MS, - ) - .run(); -} - -async function seedDeploymentRun( - database: SqliteD1Database, - input: { - appId: string; - deploymentId: string; - runId: string; - status?: string; - url?: string | null; - }, -): Promise { - await database - .prepare( - `INSERT INTO app_deployment_run ( - app_id, created_at, deployment_id, id, source_branch, source_commit_sha, - status, updated_at, url - ) - VALUES (?, ?, ?, ?, 'main', 'abc123', ?, ?, ?)`, - ) - .bind( - input.appId, - NOW_MS, - input.deploymentId, - input.runId, - input.status ?? "failed", - NOW_MS, - input.url ?? null, - ) - .run(); -} - -function runListRunId(index: number): string { - return `01J00000000000000000${String(index).padStart(6, "0")}`; -} - -const unexpectedSandboxCall = async (): Promise => { - throw new Error("Unexpected sandbox method call."); -}; - -const successfulCommandResult = (stdout = "") => ({ - exitCode: 0, - stderr: "", - stdout, - success: true as const, -}); - -function createCloudflareDeleteRecorder( - deleted: string[], - overrides: Partial = {}, -): CloudflareDeploymentClient { - return { - async deletePagesDomain(input) { - deleted.push(`pages-domain:${input.hostname}`); - }, - async deletePagesProject(input) { - deleted.push(`pages:${input.projectName}`); - }, - async deleteWorkerDomain(input) { - deleted.push(`worker-domain:${input.hostname}`); - }, - async deleteWorkerRoute(input) { - deleted.push(`worker-route:${input.hostname}`); - }, - async deleteWorkerScript(input) { - deleted.push(`worker:${input.scriptName}`); - }, - async deployWorkerModule() { - throw new Error("Unexpected Worker deploy."); - }, - async ensurePagesProject() { - throw new Error("Unexpected Pages project creation."); - }, - async ensurePagesDomain() { - throw new Error("Unexpected Pages domain creation."); - }, - async ensureWorkerDomain() { - throw new Error("Unexpected Worker domain creation."); - }, - async ensureWorkerRoute() { - throw new Error("Unexpected Worker route creation."); - }, - async getLatestPagesDeployment() { - throw new Error("Unexpected Pages deployment read."); - }, - ...overrides, - }; -} - -function createTestSandboxHandle( - id: string, - events: string[], - mode: "destroy-only" | "deployment", -): SandboxHandle { - const base = { - configureNetworkConstraints: async () => {}, - createBackup: unexpectedSandboxCall, - deleteSession: unexpectedSandboxCall, - getSession: unexpectedSandboxCall, - mkdir: unexpectedSandboxCall, - mountBucket: unexpectedSandboxCall, - restoreBackup: unexpectedSandboxCall, - startProcess: unexpectedSandboxCall, - terminal: unexpectedSandboxCall, - unmountBucket: unexpectedSandboxCall, - watch: unexpectedSandboxCall, - wsConnect: unexpectedSandboxCall, - }; - - if (mode === "destroy-only") { - return { - ...base, - createSession: unexpectedSandboxCall, - destroy: async () => { - events.push(id); - }, - exec: unexpectedSandboxCall, - readFile: unexpectedSandboxCall, - setKeepAlive: async () => {}, - writeFile: unexpectedSandboxCall, - } as SandboxHandle; - } - - const session = { - exec: async (command: string) => { - events.push(`${id}:session:${command}`); - return successfulCommandResult(); - }, - mkdir: unexpectedSandboxCall, - readFile: unexpectedSandboxCall, - startProcess: unexpectedSandboxCall, - watch: unexpectedSandboxCall, - writeFile: unexpectedSandboxCall, - }; - - return { - ...base, - createSession: async () => session, - destroy: async () => { - events.push(`${id}:destroy`); - }, - exec: async (command) => { - events.push(`${id}:${command}`); - return successfulCommandResult(command.includes("find . -type f") ? "./index.html\n" : ""); - }, - readFile: async (_path, options) => ({ - content: options?.encoding === "base64" ? "YXJjaGl2ZQ==" : "
Hello
", - encoding: options?.encoding ?? "utf8", - }), - setKeepAlive: async (keepAlive) => { - events.push(`${id}:keep-alive:${String(keepAlive)}`); - }, - writeFile: async (path) => { - events.push(`${id}:write:${path}`); - }, - } as SandboxHandle; -} - -function createWorkerDeploymentSandboxHandle(id: string, events: string[]): SandboxHandle { - const base = { - configureNetworkConstraints: async () => {}, - createBackup: unexpectedSandboxCall, - deleteSession: unexpectedSandboxCall, - getSession: unexpectedSandboxCall, - mkdir: unexpectedSandboxCall, - mountBucket: unexpectedSandboxCall, - restoreBackup: unexpectedSandboxCall, - startProcess: unexpectedSandboxCall, - terminal: unexpectedSandboxCall, - unmountBucket: unexpectedSandboxCall, - watch: unexpectedSandboxCall, - wsConnect: unexpectedSandboxCall, - }; - - return { - ...base, - createSession: unexpectedSandboxCall, - destroy: async () => { - events.push(`${id}:destroy`); - }, - exec: async (command) => { - events.push(`${id}:${command}`); - return successfulCommandResult( - command.includes("find . -type f -print") - ? "./.mosoo.toml\n./wrangler.toml\n./src/index.js\n" - : "", - ); - }, - readFile: async (path, options) => { - let content = "export default { fetch() { return new Response('ok'); } };\n"; - - if (path.endsWith(".mosoo.toml")) { - content = [ - "schema = 1", - 'name = "worker-app"', - "", - "[deploy]", - 'adapter = "cloudflare-workers"', - 'wrangler = "wrangler.toml"', - "", - ].join("\n"); - } else if (path.endsWith("wrangler.toml")) { - content = 'name = "worker-app"\nmain = "src/index.js"\n'; - } - - return { content, encoding: options?.encoding ?? "utf8" }; - }, - setKeepAlive: async (keepAlive) => { - events.push(`${id}:keep-alive:${String(keepAlive)}`); - }, - writeFile: unexpectedSandboxCall, - } as SandboxHandle; -} - -describe("app deployment service", () => { - test("creates a deployment run and queues dispatch", async () => { - const database = createDatabase(); - const { bindings, queue } = createBindings(database); - - const run = await deployApp( - bindings, - VIEWER, - { - appId: APP_ID, - configPath: ".mosoo.toml", - repoUrl: "https://github.com/samzong/awire.git", - }, - { fetch: githubFetch, nowMs: () => NOW_MS }, - ); - - expect(run).toMatchObject({ - liveUrl: null, - plannedUrl: `https://app-${APP_ID.toLowerCase()}.apps.localhost`, - sourceBranch: "main", - sourceCommitSha: "abc123", - status: "queued", - }); - expect(queue.sent).toHaveLength(1); - - const command = await database.app().select().from(apiCommandsTable).limit(1).get(); - - expect(command).toMatchObject({ - kind: "app_deployment_run_dispatch", - status: "queued", - }); - expect(JSON.parse(command?.payloadJson ?? "{}")).toEqual({ - appDeploymentRunId: run.id, - }); - - const deployment = await getAppDeployment(bindings, VIEWER, APP_ID); - expect(deployment?.latestRun?.id).toBe(run.id); - - await database.prepare("UPDATE app_deployment SET latest_run_id = NULL").run(); - - const deploymentAfterPointerDrift = await getAppDeployment(bindings, VIEWER, APP_ID); - expect(deploymentAfterPointerDrift?.latestRun?.id).toBe(run.id); - }); - - test("redrives a dropped deployment dispatch without leaving its run queued forever", async () => { - const database = createDatabase(); - const { bindings } = createBindings(database); - const deliveredCommandIds: string[] = []; - let queueUnavailable = true; - const deferredBindings = { - ...bindings, - API_COMMAND_QUEUE: { - async send(input: { commandId: string }): Promise { - if (queueUnavailable) { - throw new Error("Queue response timed out."); - } - - deliveredCommandIds.push(input.commandId); - }, - }, - }; - - const run = await deployApp( - deferredBindings, - VIEWER, - { appId: APP_ID, repoUrl: "https://github.com/samzong/awire" }, - { fetch: githubFetch, nowMs: () => NOW_MS }, - ); - - const command = await database - .app() - .select({ - lastErrorCode: apiCommandsTable.lastErrorCode, - status: apiCommandsTable.status, - }) - .from(apiCommandsTable) - .get(); - const runRow = await database - .app() - .select({ - errorCode: appDeploymentRunsTable.errorCode, - status: appDeploymentRunsTable.status, - }) - .from(appDeploymentRunsTable) - .where(eq(appDeploymentRunsTable.id, run.id)) - .get(); - - expect(command).toEqual({ - lastErrorCode: API_COMMAND_QUEUE_SEND_FAILED_CODE, - status: "queued", - }); - expect(runRow).toEqual({ errorCode: null, status: "queued" }); - - queueUnavailable = false; - await createApiWorker().scheduled( - { scheduledTime: NOW_MS } as ScheduledController, - deferredBindings as ApiBindings, - ); - - const redrivenCommand = await database - .app() - .select({ - id: apiCommandsTable.id, - lastErrorCode: apiCommandsTable.lastErrorCode, - status: apiCommandsTable.status, - }) - .from(apiCommandsTable) - .where(eq(apiCommandsTable.dedupeKey, createAppDeploymentRunDispatchDedupeKey(run.id))) - .get(); - const runner: AppDeploymentBuildRunner = { - async build() {}, - async deploy() { - return { - externalDeploymentId: "pages-deployment-after-redrive", - externalProjectId: "pages-project-after-redrive", - externalVersionId: null, - url: `https://app-${APP_ID.toLowerCase()}.apps.localhost`, - }; - }, - async prepare() { - return { - repoDir: "/repo", - snapshot: { files: { "index.html": "
Recovered
" } }, - }; - }, - }; - - expect(deliveredCommandIds).toContain(redrivenCommand?.id); - expect(redrivenCommand).toMatchObject({ lastErrorCode: null, status: "queued" }); - - await dispatchAppDeploymentRun( - deferredBindings as ApiBindings, - { appDeploymentRunId: run.id }, - { runner }, - ); - - const status = await getAppDeploymentStatus(deferredBindings, VIEWER, APP_ID); - expect(status).toMatchObject({ status: "success" }); - }); - - test("rejects a second deploy while a run is active", async () => { - const database = createDatabase(); - const { bindings } = createBindings(database); - - await deployApp( - bindings, - VIEWER, - { appId: APP_ID, repoUrl: "https://github.com/samzong/awire" }, - { fetch: githubFetch, nowMs: () => NOW_MS }, - ); - - await expect( - deployApp( - bindings, - VIEWER, - { appId: APP_ID, repoUrl: "https://github.com/samzong/awire" }, - { fetch: githubFetch, nowMs: () => NOW_MS + 1 }, - ), - ).rejects.toThrow("An App deployment run is already active."); - }); - - test("recovers an active deployment run without a dispatch command", async () => { - const database = createDatabase(); - const { bindings } = createBindings(database); - - const firstRun = await deployApp( - bindings, - VIEWER, - { appId: APP_ID, repoUrl: "https://github.com/samzong/awire" }, - { fetch: githubFetch, nowMs: () => NOW_MS }, - ); - - await database.prepare("DELETE FROM api_command").run(); - await setDeploymentRunUpdatedAt(database, firstRun.id, 1); - - const secondRun = await deployApp( - bindings, - VIEWER, - { appId: APP_ID, repoUrl: "https://github.com/samzong/awire" }, - { fetch: githubFetch, nowMs: () => NOW_MS + 1 }, - ); - - expect(secondRun.id).not.toBe(firstRun.id); - expect(secondRun.status).toBe("queued"); - - const firstRunRow = await database - .app() - .select() - .from(appDeploymentRunsTable) - .where(eq(appDeploymentRunsTable.id, firstRun.id)) - .limit(1) - .get(); - - expect(firstRunRow).toMatchObject({ - errorCode: "deployment_dispatch_missing", - status: "failed", - }); - }); - - test("recovers an active deployment run with an expired running dispatch command", async () => { - const database = createDatabase(); - const { bindings } = createBindings(database); - - const firstRun = await deployApp( - bindings, - VIEWER, - { appId: APP_ID, repoUrl: "https://github.com/samzong/awire" }, - { fetch: githubFetch, nowMs: () => NOW_MS }, - ); - - await seedExpiredRunningDispatch(database, firstRun.id); - - const secondRun = await deployApp( - bindings, - VIEWER, - { appId: APP_ID, repoUrl: "https://github.com/samzong/awire" }, - { fetch: githubFetch, nowMs: () => NOW_MS + 1 }, - ); - - expect(secondRun.id).not.toBe(firstRun.id); - expect(secondRun.status).toBe("queued"); - - const firstRunRow = await database - .app() - .select() - .from(appDeploymentRunsTable) - .where(eq(appDeploymentRunsTable.id, firstRun.id)) - .limit(1) - .get(); - - expect(firstRunRow).toMatchObject({ - errorCode: "deployment_dispatch_expired", - status: "failed", - }); - }); - - test("recovers an active deployment run with an expired running dispatch from reads", async () => { - const database = createDatabase(); - const { bindings } = createBindings(database); - - const run = await deployApp( - bindings, - VIEWER, - { appId: APP_ID, repoUrl: "https://github.com/samzong/awire" }, - { fetch: githubFetch, nowMs: () => NOW_MS }, - ); - - await seedExpiredRunningDispatch(database, run.id); - - await expect(getAppDeploymentStatus(bindings, VIEWER, APP_ID)).resolves.toMatchObject({ - errorCode: "deployment_dispatch_expired", - id: run.id, - status: "failed", - }); - - await expect(getAppDeployment(bindings, VIEWER, APP_ID)).resolves.toMatchObject({ - latestRun: { - errorCode: "deployment_dispatch_expired", - id: run.id, - status: "failed", - }, - }); - - await expect(listAppDeploymentRuns(bindings, VIEWER, APP_ID, 10)).resolves.toEqual([ - expect.objectContaining({ - errorCode: "deployment_dispatch_expired", - id: run.id, - status: "failed", - }), - ]); - - const runRow = await database - .app() - .select() - .from(appDeploymentRunsTable) - .where(eq(appDeploymentRunsTable.id, run.id)) - .limit(1) - .get(); - - expect(runRow).toMatchObject({ - errorCode: "deployment_dispatch_expired", - status: "failed", - }); - }); - - test("fails an active deployment run when dispatch retries are exhausted", async () => { - const database = createDatabase(); - const { bindings } = createBindings(database); - - const firstRun = await deployApp( - bindings, - VIEWER, - { appId: APP_ID, repoUrl: "https://github.com/samzong/awire" }, - { fetch: githubFetch, nowMs: () => NOW_MS }, - ); - - await seedExhaustedRunningDispatch(database, firstRun.id); - - await expect(getAppDeploymentStatus(bindings, VIEWER, APP_ID)).resolves.toMatchObject({ - errorCode: APP_DEPLOYMENT_RUN_DISPATCH_RETRY_EXHAUSTED_CODE, - errorMessage: expect.stringContaining("Container is starting"), - id: firstRun.id, - status: "failed", - }); - - const dispatchCommand = await database - .app() - .select({ - lastErrorCode: apiCommandsTable.lastErrorCode, - status: apiCommandsTable.status, - }) - .from(apiCommandsTable) - .where(eq(apiCommandsTable.dedupeKey, createAppDeploymentRunDispatchDedupeKey(firstRun.id))) - .limit(1) - .get(); - - expect(dispatchCommand).toMatchObject({ - lastErrorCode: APP_DEPLOYMENT_RUN_DISPATCH_RETRY_EXHAUSTED_CODE, - status: "failed", - }); - - const secondRun = await deployApp( - bindings, - VIEWER, - { appId: APP_ID, repoUrl: "https://github.com/samzong/awire" }, - { fetch: githubFetch, nowMs: () => NOW_MS + 1 }, - ); - - expect(secondRun.id).not.toBe(firstRun.id); - expect(secondRun.status).toBe("queued"); - }); - - test("keeps a fresh active deployment run without a dispatch command active", async () => { - const database = createDatabase(); - const { bindings } = createBindings(database); - - const firstRun = await deployApp( - bindings, - VIEWER, - { appId: APP_ID, repoUrl: "https://github.com/samzong/awire" }, - { fetch: githubFetch, nowMs: currentTimestampMs }, - ); - - await database.prepare("DELETE FROM api_command").run(); - await setDeploymentRunUpdatedAt(database, firstRun.id, currentTimestampMs()); - - await expect( - deployApp( - bindings, - VIEWER, - { appId: APP_ID, repoUrl: "https://github.com/samzong/awire" }, - { fetch: githubFetch, nowMs: currentTimestampMs }, - ), - ).rejects.toThrow("An App deployment run is already active."); - - const firstRunRow = await database - .app() - .select() - .from(appDeploymentRunsTable) - .where(eq(appDeploymentRunsTable.id, firstRun.id)) - .limit(1) - .get(); - - expect(firstRunRow).toMatchObject({ - errorCode: null, - status: "queued", - }); - }); - - test("dispatches a queued deployment run to success", async () => { - const database = createDatabase(); - const { bindings } = createBindings(database); - const targetUrl = `https://app-${APP_ID.toLowerCase()}.apps.localhost`; - let buildPlanName: string | null = null; - const runner: AppDeploymentBuildRunner = { - async build({ plan }) { - buildPlanName = plan.generatedWranglerConfig; - }, - async deploy() { - return { - externalDeploymentId: "pages-deployment-1", - externalProjectId: "pages-project-1", - externalVersionId: null, - url: targetUrl, - }; - }, - async prepare() { - return { - repoDir: "/repo", - snapshot: { files: { "index.html": "
Hello
" } }, - }; - }, - }; - - const run = await deployApp( - bindings, - VIEWER, - { appId: APP_ID, repoUrl: "https://github.com/samzong/awire" }, - { fetch: githubFetch, nowMs: () => NOW_MS }, - ); - - await dispatchAppDeploymentRun( - bindings as ApiBindings, - { appDeploymentRunId: run.id }, - { - runner, - }, - ); - - const status = await getAppDeploymentStatus(bindings, VIEWER, APP_ID); - const deployment = await getAppDeployment(bindings, VIEWER, APP_ID); - const runRow = await database.app().select().from(appDeploymentRunsTable).limit(1).get(); - - expect(buildPlanName).toContain(`name = "app-${APP_ID.toLowerCase()}"`); - expect(status).toMatchObject({ - liveUrl: targetUrl, - status: "success", - }); - expect(deployment?.liveUrl).toBe(targetUrl); - expect(runRow).toMatchObject({ - externalDeploymentId: "pages-deployment-1", - externalProjectId: "pages-project-1", - status: "success", - targetKind: "cloudflare_pages", - targetProjectName: `app-${APP_ID.toLowerCase()}`, - url: targetUrl, - }); - }); - - test("does not delete stable Cloudflare resources when an inactive run finishes", async () => { - const database = createDatabase(); - const { bindings } = createBindings(database); - const deleted: string[] = []; - const targetUrl = `https://app-${APP_ID.toLowerCase()}.apps.localhost`; - const run = await deployApp( - bindings, - VIEWER, - { appId: APP_ID, repoUrl: "https://github.com/samzong/awire" }, - { fetch: githubFetch, nowMs: () => NOW_MS }, - ); - const runner: AppDeploymentBuildRunner = { - async build() {}, - async deploy() { - await database - .prepare("UPDATE app_deployment_run SET status = 'failed' WHERE id = ?") - .bind(run.id) - .run(); - return { - externalDeploymentId: "pages-deployment-1", - externalProjectId: "pages-project-1", - externalVersionId: null, - url: targetUrl, - }; - }, - async prepare() { - return { - repoDir: "/repo", - snapshot: { files: { "index.html": "
Hello
" } }, - }; - }, - }; - - await dispatchAppDeploymentRun( - bindings as ApiBindings, - { appDeploymentRunId: run.id }, - { - cloudflareClient: createCloudflareDeleteRecorder(deleted), - runner, - }, - ); - - const runRow = await database - .app() - .select() - .from(appDeploymentRunsTable) - .where(eq(appDeploymentRunsTable.id, run.id)) - .limit(1) - .get(); - - expect(deleted).toEqual([]); - expect(runRow?.status).toBe("failed"); - }); - - test("compensates resources created after deployment deletion", async () => { - const database = createDatabase(); - const { bindings } = createBindings(database); - const deleted: string[] = []; - const externallyCreated: string[] = []; - const cloudflareClient = createCloudflareDeleteRecorder(deleted); - const run = await deployApp( - bindings, - VIEWER, - { appId: APP_ID, repoUrl: "https://github.com/samzong/awire" }, - { fetch: githubFetch, nowMs: () => NOW_MS }, - ); - const runner: AppDeploymentBuildRunner = { - async build() {}, - async deploy() { - await deleteAppDeployment(bindings, VIEWER, { appId: APP_ID }, { cloudflareClient }); - externallyCreated.push(`pages:${APP_ID}`); - - return { - externalDeploymentId: "pages-deployment-after-delete", - externalProjectId: "pages-project-after-delete", - externalVersionId: null, - url: `https://app-${APP_ID.toLowerCase()}.apps.localhost`, - }; - }, - async prepare() { - return { - repoDir: "/repo", - snapshot: { files: { "index.html": "
Hello
" } }, - }; - }, - }; - - await dispatchAppDeploymentRun( - bindings as ApiBindings, - { appDeploymentRunId: run.id }, - { cloudflareClient, runner }, - ); - - const deployment = await database - .app() - .select({ deletedAt: appDeploymentsTable.deletedAt }) - .from(appDeploymentsTable) - .where(eq(appDeploymentsTable.id, run.deploymentId)) - .get(); - const runRow = await database - .app() - .select({ - errorCode: appDeploymentRunsTable.errorCode, - status: appDeploymentRunsTable.status, - }) - .from(appDeploymentRunsTable) - .where(eq(appDeploymentRunsTable.id, run.id)) - .get(); - - expect(externallyCreated).toEqual([`pages:${APP_ID}`]); - expect(deployment?.deletedAt).toBeNumber(); - expect(runRow).toEqual({ errorCode: "deployment_deleted", status: "failed" }); - expect(deleted).toHaveLength(10); - expect(deleted.filter((entry) => entry.startsWith("pages:"))).toHaveLength(2); - expect(deleted.filter((entry) => entry.startsWith("worker:"))).toHaveLength(2); - }); - - test("does not compensate a deleted deployment after a replacement is active", async () => { - const database = createDatabase(); - const { bindings } = createBindings(database); - const deleted: string[] = []; - const cloudflareClient = createCloudflareDeleteRecorder(deleted); - const run = await deployApp( - bindings, - VIEWER, - { appId: APP_ID, repoUrl: "https://github.com/samzong/awire" }, - { fetch: githubFetch, nowMs: () => NOW_MS }, - ); - const runner: AppDeploymentBuildRunner = { - async build() {}, - async deploy() { - await deleteAppDeployment(bindings, VIEWER, { appId: APP_ID }, { cloudflareClient }); - await deployApp( - bindings, - VIEWER, - { appId: APP_ID, repoUrl: "https://github.com/samzong/awire" }, - { fetch: githubFetch, nowMs: () => NOW_MS + 1 }, - ); - - return { - externalDeploymentId: "pages-deployment-after-replacement", - externalProjectId: "pages-project-after-replacement", - externalVersionId: null, - url: `https://app-${APP_ID.toLowerCase()}.apps.localhost`, - }; - }, - async prepare() { - return { - repoDir: "/repo", - snapshot: { files: { "index.html": "
Hello
" } }, - }; - }, - }; - - await dispatchAppDeploymentRun( - bindings as ApiBindings, - { appDeploymentRunId: run.id }, - { cloudflareClient, runner }, - ); - - const activeDeployment = await getAppDeployment(bindings, VIEWER, APP_ID); - - expect(activeDeployment).not.toBeNull(); - expect(deleted).toHaveLength(5); - }); - - test("deletes the active deployment and fails the active run", async () => { - const database = createDatabase(); - const { bindings } = createBindings(database); - const deleted: string[] = []; - const destroyed: string[] = []; - (bindings as ApiBindings).runtimeSubjectHandleFactory = (runtimeSubjectId) => - createTestSandboxHandle(runtimeSubjectId, destroyed, "destroy-only"); - - const run = await deployApp( - bindings, - VIEWER, - { appId: APP_ID, repoUrl: "https://github.com/samzong/awire" }, - { fetch: githubFetch, nowMs: () => NOW_MS }, - ); - - await expect( - deleteAppDeployment( - bindings, - VIEWER, - { appId: APP_ID }, - { - cloudflareClient: createCloudflareDeleteRecorder(deleted), - }, - ), - ).resolves.toEqual({ ok: true }); - - await expect(getAppDeployment(bindings, VIEWER, APP_ID)).resolves.toBeNull(); - - const status = await getAppDeploymentStatus(bindings, VIEWER, APP_ID); - const deploymentRow = await database.app().select().from(appDeploymentsTable).limit(1).get(); - const runRow = await database.app().select().from(appDeploymentRunsTable).limit(1).get(); - - expect(status).toMatchObject({ - errorCode: "deployment_deleted", - id: run.id, - status: "failed", - }); - expect(deploymentRow?.deletedAt).toBeNumber(); - expect(runRow?.status).toBe("failed"); - expect(deleted).toContain(`pages-domain:app-${APP_ID.toLowerCase()}.apps.localhost`); - expect(deleted).toContain(`pages:app-${APP_ID.toLowerCase()}`); - expect(deleted).toContain(`worker-domain:app-${APP_ID.toLowerCase()}.apps.localhost`); - expect(deleted).toContain(`worker-route:app-${APP_ID.toLowerCase()}.apps.localhost`); - expect(deleted).toContain(`worker:app-${APP_ID.toLowerCase()}`); - expect(destroyed).toContain(`${run.id}-build`); - expect(destroyed).toContain(`${run.id}-deploy`); - }); - - test("keeps a deployment retryable when Cloudflare cleanup fails", async () => { - const database = createDatabase(); - const { bindings } = createBindings(database); - const failedDeletes: string[] = []; - const successfulDeletes: string[] = []; - const run = await deployApp( - bindings, - VIEWER, - { appId: APP_ID, repoUrl: "https://github.com/samzong/awire" }, - { fetch: githubFetch, nowMs: () => NOW_MS }, - ); - const failingClient = createCloudflareDeleteRecorder(failedDeletes, { - async deletePagesProject(input) { - failedDeletes.push(`pages:${input.projectName}`); - throw new Error("Cloudflare API unavailable."); - }, - }); - - await expect( - deleteAppDeployment(bindings, VIEWER, { appId: APP_ID }, { cloudflareClient: failingClient }), - ).rejects.toMatchObject({ - code: API_ERROR_CODE.appDeploymentCleanupFailed, - name: "ApiError", - } satisfies Partial); - - const deploymentAfterFailure = await database - .app() - .select() - .from(appDeploymentsTable) - .where(eq(appDeploymentsTable.id, run.deploymentId)) - .get(); - const runAfterFailure = await database - .app() - .select() - .from(appDeploymentRunsTable) - .where(eq(appDeploymentRunsTable.id, run.id)) - .get(); - - expect(deploymentAfterFailure).toMatchObject({ deletedAt: null }); - expect(runAfterFailure).toMatchObject({ - errorCode: "deployment_deleted", - status: "failed", - }); - expect(failedDeletes).toHaveLength(5); - - await expect( - deleteAppDeployment( - bindings, - VIEWER, - { appId: APP_ID }, - { cloudflareClient: createCloudflareDeleteRecorder(successfulDeletes) }, - ), - ).resolves.toEqual({ ok: true }); - - const deploymentAfterRetry = await database - .app() - .select({ deletedAt: appDeploymentsTable.deletedAt }) - .from(appDeploymentsTable) - .where(eq(appDeploymentsTable.id, run.deploymentId)) - .get(); - - expect(deploymentAfterRetry?.deletedAt).toBeNumber(); - expect(successfulDeletes).toHaveLength(5); - }); - - test("does not expose a live URL after deleting a successful deployment", async () => { - const database = createDatabase(); - const { bindings } = createBindings(database); - const targetUrl = `https://app-${APP_ID.toLowerCase()}.apps.localhost`; - const runner: AppDeploymentBuildRunner = { - async build() {}, - async deploy() { - return { - externalDeploymentId: "pages-deployment-1", - externalProjectId: "pages-project-1", - externalVersionId: null, - url: targetUrl, - }; - }, - async prepare() { - return { - repoDir: "/repo", - snapshot: { files: { "index.html": "
Hello
" } }, - }; - }, - }; - - const run = await deployApp( - bindings, - VIEWER, - { appId: APP_ID, repoUrl: "https://github.com/samzong/awire" }, - { fetch: githubFetch, nowMs: () => NOW_MS }, - ); - await dispatchAppDeploymentRun( - bindings as ApiBindings, - { appDeploymentRunId: run.id }, - { - runner, - }, - ); - await deleteAppDeployment( - bindings, - VIEWER, - { appId: APP_ID }, - { - cloudflareClient: createCloudflareDeleteRecorder([]), - }, - ); - - await expect(getAppDeploymentStatus(bindings, VIEWER, APP_ID)).resolves.toMatchObject({ - liveUrl: null, - status: "success", - }); - }); - - test("revokes a bound Agent capability after local deployment cleanup succeeds", async () => { - const database = createDatabase(); - const { bindings } = createBindings(database); - const targetUrl = `https://app-${APP_ID.toLowerCase()}.apps.localhost`; - const runner: AppDeploymentBuildRunner = { - async build() {}, - async deploy() { - return { - externalDeploymentId: "pages-deployment-1", - externalProjectId: "pages-project-1", - externalVersionId: null, - url: targetUrl, - }; - }, - async prepare() { - return { - repoDir: "/repo", - snapshot: { files: { "index.html": "
Hello
" } }, - }; - }, - }; - const run = await deployApp( - bindings, - VIEWER, - { appId: APP_ID, repoUrl: "https://github.com/samzong/awire" }, - { fetch: githubFetch, nowMs: () => NOW_MS }, - ); - - await dispatchAppDeploymentRun( - bindings as ApiBindings, - { appDeploymentRunId: run.id }, - { runner }, - ); - await database - .prepare("UPDATE app_deployment_run SET plan_json = ? WHERE id = ?") - .bind( - JSON.stringify({ - agentBindings: [{ env: "MOSOO_AGENT", expose: "public_thread", name: "Support" }], - }), - run.id, - ) - .run(); - - const authority = { - appId: run.appId, - binding: { env: "MOSOO_AGENT", expose: "public_thread" as const, name: "Support" }, - deploymentId: run.deploymentId, - deploymentRunId: run.id, - }; - - await expect(getDeploymentAgentCapabilityAuthority(database, authority)).resolves.toEqual({ - authorized: true, - }); - - await deleteAppDeployment( - bindings, - VIEWER, - { appId: APP_ID }, - { - cloudflareClient: createCloudflareDeleteRecorder([]), - }, - ); - - await expect(getDeploymentAgentCapabilityAuthority(database, authority)).resolves.toEqual({ - authorized: false, - reason: "deployment_deleted", - }); - }); - - test("uses the Pages deployment URL while the custom domain is pending", async () => { - const database = createDatabase(); - const { bindings } = createBindings(database); - const calls: string[] = []; - const pagesUrl = "https://app-example.pages.dev"; - const cloudflareClient = createCloudflareDeleteRecorder([], { - ensurePagesDomain: async () => ({ status: "initializing" }), - ensurePagesProject: async () => ({ projectId: "pages-project-1" }), - getLatestPagesDeployment: async () => ({ - deploymentId: "pages-deployment-1", - url: pagesUrl, - }), - }); - (bindings as ApiBindings).runtimeSubjectHandleFactory = (runtimeSubjectId) => - createTestSandboxHandle(runtimeSubjectId, calls, "deployment"); - - const run = await deployApp( - bindings, - VIEWER, - { appId: APP_ID, repoUrl: "https://github.com/samzong/awire" }, - { fetch: githubFetch, nowMs: () => NOW_MS }, - ); - - await dispatchAppDeploymentRun( - bindings as ApiBindings, - { appDeploymentRunId: run.id }, - { cloudflareClient }, - ); - - const status = await getAppDeploymentStatus(bindings, VIEWER, APP_ID); - const runRow = await database.app().select().from(appDeploymentRunsTable).limit(1).get(); - - expect(status).toMatchObject({ - liveUrl: pagesUrl, - status: "success", - }); - expect(runRow).toMatchObject({ - externalDeploymentId: "pages-deployment-1", - externalProjectId: "pages-project-1", - status: "success", - url: pagesUrl, - }); - expect(calls.some((call) => call.includes("wrangler pages deploy"))).toBe(true); - }); - - test("deploys worker modules with a Worker route and custom domain", async () => { - const database = createDatabase(); - const { bindings } = createBindings(database); - const calls: string[] = []; - const cloudflareCalls: string[] = []; - (bindings as ApiBindings).runtimeSubjectHandleFactory = (runtimeSubjectId) => - createWorkerDeploymentSandboxHandle(runtimeSubjectId, calls); - - const cloudflareClient = createCloudflareDeleteRecorder([], { - async deployWorkerModule(input) { - cloudflareCalls.push( - `worker:${input.scriptName}:${input.mainModuleName}:${input.scriptContent.trim()}`, - ); - return { deploymentId: "worker-deployment-1", versionId: "worker-version-1" }; - }, - async ensureWorkerDomain(input) { - cloudflareCalls.push(`worker-domain:${input.hostname}:${input.scriptName}`); - }, - async ensureWorkerRoute(input) { - cloudflareCalls.push(`worker-route:${input.hostname}:${input.scriptName}`); - }, - }); - - const run = await deployApp( - bindings, - VIEWER, - { appId: APP_ID, configPath: ".mosoo.toml", repoUrl: "https://github.com/samzong/awire" }, - { fetch: githubFetch, nowMs: () => NOW_MS }, - ); - - await dispatchAppDeploymentRun( - bindings as ApiBindings, - { appDeploymentRunId: run.id }, - { cloudflareClient }, - ); - - const targetName = `app-${APP_ID.toLowerCase()}`; - const hostname = `${targetName}.apps.localhost`; - const targetUrl = `https://${hostname}`; - const status = await getAppDeploymentStatus(bindings, VIEWER, APP_ID); - const runRow = await database.app().select().from(appDeploymentRunsTable).limit(1).get(); - - expect(status).toMatchObject({ - liveUrl: targetUrl, - status: "success", - }); - expect(runRow).toMatchObject({ - externalDeploymentId: "worker-deployment-1", - externalProjectId: null, - externalVersionId: "worker-version-1", - status: "success", - targetKind: "cloudflare_worker", - targetScriptName: targetName, - url: targetUrl, - }); - expect(cloudflareCalls).toEqual([ - `worker:${targetName}:index.js:export default { fetch() { return new Response('ok'); } };`, - `worker-route:${hostname}:${targetName}`, - `worker-domain:${hostname}:${targetName}`, - ]); - }); - - test("dead letters active deployment runs without overwriting terminal runs", async () => { - const database = createDatabase(); - const { bindings, queue } = createBindings(database); - - const run = await deployApp( - bindings, - VIEWER, - { appId: APP_ID, repoUrl: "https://github.com/samzong/awire" }, - { fetch: githubFetch, nowMs: () => NOW_MS }, - ); - const queued = queue.sent[0]; - - if (queued === undefined) { - throw new Error("Expected deployment dispatch queue message."); - } - - await database.app().update(apiCommandsTable).set({ payloadJson: "{}" }).run(); - - await processApiCommandDeadLetterMessage( - bindings as ApiBindings, - createRecordedQueueMessage({ body: queued.body }).message, - () => NOW_MS + 1, - ); - - await expect(getAppDeploymentStatus(bindings, VIEWER, APP_ID)).resolves.toMatchObject({ - errorCode: "queue_dead_lettered", - id: run.id, - status: "failed", - }); - - await deleteAppDeployment( - bindings, - VIEWER, - { appId: APP_ID }, - { - cloudflareClient: createCloudflareDeleteRecorder([]), - }, - ); - await processApiCommandDeadLetterMessage( - bindings as ApiBindings, - createRecordedQueueMessage({ body: queued.body }).message, - () => NOW_MS + 2, - ); - - await expect(getAppDeploymentStatus(bindings, VIEWER, APP_ID)).resolves.toMatchObject({ - errorCode: "queue_dead_lettered", - id: run.id, - status: "failed", - }); - }); - - test("lists deployment runs newest-first", async () => { - const database = createDatabase(); - const { bindings } = createBindings(database); - const targetUrl = `https://app-${APP_ID.toLowerCase()}.apps.localhost`; - - await seedDeployment(database, { appId: APP_ID, deploymentId: DEPLOYMENT_ID }); - await seedDeploymentRun(database, { - appId: APP_ID, - deploymentId: DEPLOYMENT_ID, - runId: runListRunId(1), - status: "failed", - }); - await seedDeploymentRun(database, { - appId: APP_ID, - deploymentId: DEPLOYMENT_ID, - runId: runListRunId(2), - status: "success", - url: targetUrl, - }); - await seedDeploymentRun(database, { - appId: APP_ID, - deploymentId: DEPLOYMENT_ID, - runId: runListRunId(3), - status: "queued", - }); - await seedQueuedDispatch(database, runListRunId(3)); - - const runs = await listAppDeploymentRuns(bindings, VIEWER, APP_ID); - - expect(runs.map((run) => run.id)).toEqual([runListRunId(3), runListRunId(2), runListRunId(1)]); - expect(runs[0]).toMatchObject({ liveUrl: null, status: "queued" }); - expect(runs[1]).toMatchObject({ - appId: APP_ID, - deploymentId: DEPLOYMENT_ID, - liveUrl: targetUrl, - plannedUrl: targetUrl, - sourceBranch: "main", - sourceCommitSha: "abc123", - status: "success", - }); - }); - - test("applies the default run list limit and caps requested limits", async () => { - const database = createDatabase(); - const { bindings } = createBindings(database); - - await seedDeployment(database, { appId: APP_ID, deploymentId: DEPLOYMENT_ID }); - - for (let index = 1; index <= 55; index += 1) { - await seedDeploymentRun(database, { - appId: APP_ID, - deploymentId: DEPLOYMENT_ID, - runId: runListRunId(index), - }); - } - - const defaultRuns = await listAppDeploymentRuns(bindings, VIEWER, APP_ID); - expect(defaultRuns).toHaveLength(20); - expect(defaultRuns[0]?.id).toBe(runListRunId(55)); - expect(defaultRuns[19]?.id).toBe(runListRunId(36)); - - const limitedRuns = await listAppDeploymentRuns(bindings, VIEWER, APP_ID, 5); - expect(limitedRuns.map((run) => run.id)).toEqual([ - runListRunId(55), - runListRunId(54), - runListRunId(53), - runListRunId(52), - runListRunId(51), - ]); - - const cappedRuns = await listAppDeploymentRuns(bindings, VIEWER, APP_ID, 200); - expect(cappedRuns).toHaveLength(50); - expect(cappedRuns[49]?.id).toBe(runListRunId(6)); - - await expect(listAppDeploymentRuns(bindings, VIEWER, APP_ID, 0)).rejects.toThrow( - "limit must be a positive integer.", - ); - }); - - test("does not list deployment runs from another app", async () => { - const database = createDatabase(); - const { bindings } = createBindings(database); - - await database - .prepare( - `INSERT INTO app (id, organization_id, owner_account_id, name, created_at, updated_at) - VALUES (?, '01J00000000000000000000006', ?, 'Other App', 1, 1)`, - ) - .bind(OTHER_APP_ID, OWNER_ID) - .run(); - await seedDeployment(database, { appId: APP_ID, deploymentId: DEPLOYMENT_ID }); - await seedDeployment(database, { appId: OTHER_APP_ID, deploymentId: OTHER_DEPLOYMENT_ID }); - await seedDeploymentRun(database, { - appId: APP_ID, - deploymentId: DEPLOYMENT_ID, - runId: runListRunId(1), - }); - await seedDeploymentRun(database, { - appId: OTHER_APP_ID, - deploymentId: OTHER_DEPLOYMENT_ID, - runId: runListRunId(2), - }); - - const runs = await listAppDeploymentRuns(bindings, VIEWER, APP_ID); - const otherRuns = await listAppDeploymentRuns(bindings, VIEWER, OTHER_APP_ID); - - expect(runs.map((run) => run.id)).toEqual([runListRunId(1)]); - expect(otherRuns.map((run) => run.id)).toEqual([runListRunId(2)]); - }); - - test("keeps listing runs after deleteAppDeployment and hides their live URLs", async () => { - const database = createDatabase(); - const { bindings } = createBindings(database); - const targetUrl = `https://app-${APP_ID.toLowerCase()}.apps.localhost`; - const runner: AppDeploymentBuildRunner = { - async build() {}, - async deploy() { - return { - externalDeploymentId: "pages-deployment-1", - externalProjectId: "pages-project-1", - externalVersionId: null, - url: targetUrl, - }; - }, - async prepare() { - return { - repoDir: "/repo", - snapshot: { files: { "index.html": "
Hello
" } }, - }; - }, - }; - - const run = await deployApp( - bindings, - VIEWER, - { appId: APP_ID, repoUrl: "https://github.com/samzong/awire" }, - { fetch: githubFetch, nowMs: () => NOW_MS }, - ); - await dispatchAppDeploymentRun( - bindings as ApiBindings, - { appDeploymentRunId: run.id }, - { - runner, - }, - ); - await deleteAppDeployment( - bindings, - VIEWER, - { appId: APP_ID }, - { - cloudflareClient: createCloudflareDeleteRecorder([]), - }, - ); - - // The deployment is soft-deleted: run history stays listed, but liveUrl is - // suppressed because the deployment row carries deletedAt. - const runs = await listAppDeploymentRuns(bindings, VIEWER, APP_ID); - - expect(runs).toHaveLength(1); - expect(runs[0]).toMatchObject({ - id: run.id, - liveUrl: null, - status: "success", - }); - }); -}); diff --git a/apps/api/tests/app-overview.test.ts b/apps/api/tests/app-overview.test.ts index b1e83887..ec6b433a 100644 --- a/apps/api/tests/app-overview.test.ts +++ b/apps/api/tests/app-overview.test.ts @@ -1,9 +1,8 @@ import { describe, expect, test } from "bun:test"; -import { isInputObjectType, isObjectType } from "graphql"; +import { isObjectType } from "graphql"; import { createGraphQLSchema } from "../src/adapters/graphql/create-graphql-schema"; -import { createAppDeploymentRunDispatchDedupeKey } from "../src/modules/api-command/application/api-command-enqueue"; import { getAppOverview, getControlPlaneOverview, @@ -11,13 +10,6 @@ import { import type { AuthenticatedViewer } from "../src/modules/auth/application/viewer-auth.service"; import { createApiTestFixture } from "./helpers/api-test-fixture"; -const OVERVIEW_DEPLOYMENT_ID = "01J000000000000000000000D1"; -const OVERVIEW_DEPLOYMENT_RUN_ID = "01J000000000000000000000D2"; - -function createOverviewDeploymentUrl(appId: string, domain: string): string { - return `https://app-${appId.toLowerCase()}.${domain}`; -} - function makeForeignViewer(): AuthenticatedViewer { return { email: "foreign@example.com", @@ -114,125 +106,29 @@ async function insertOverviewCredentialMetadata( .run(); } -async function insertOverviewDeploymentMetadata( - fixture: Awaited>, -): Promise<{ liveUrl: string }> { - const liveUrl = createOverviewDeploymentUrl( - fixture.ids.appId, - fixture.bindings.MOSOO_APP_DEPLOYMENT_DOMAIN, - ); - - await fixture.database - .prepare( - `INSERT INTO app_deployment ( - app_id, - created_at, - default_branch, - deleted_at, - id, - last_successful_url, - mosoo_subdomain, - owner_account_id, - repo_name, - repo_owner, - repo_url, - source_kind, - updated_at - ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`, - ) - .bind( - fixture.ids.appId, - 1, - "main", - null, - OVERVIEW_DEPLOYMENT_ID, - liveUrl, - `app-${fixture.ids.appId.toLowerCase()}`, - fixture.viewer.id, - "awire", - "samzong", - "https://github.com/samzong/awire.git", - "github_public", - 2, - ) - .run(); - - await fixture.database - .prepare( - `INSERT INTO app_deployment_run ( - app_id, - created_at, - deployment_id, - error_code, - error_message, - id, - source_branch, - source_commit_sha, - status, - target_kind, - updated_at, - url - ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`, - ) - .bind( - fixture.ids.appId, - 1, - OVERVIEW_DEPLOYMENT_ID, - null, - null, - OVERVIEW_DEPLOYMENT_RUN_ID, - "main", - "abc123", - "success", - "cloudflare_pages", - 2, - liveUrl, - ) - .run(); - - return { liveUrl }; -} - describe("App overview", () => { test("keeps the GraphQL overview surface App-scoped and secret-free", () => { const schema = createGraphQLSchema(); const query = schema.getQueryType(); - const mutation = schema.getMutationType(); const appOverview = schema.getType("AppOverview"); const credential = schema.getType("AppOverviewProviderCredential"); - const deployment = schema.getType("AppDeployment"); - const deploymentRun = schema.getType("AppDeploymentRun"); - const deployInput = schema.getType("DeployAppInput"); - if ( - !query || - !mutation || - !isObjectType(appOverview) || - !isObjectType(credential) || - !isObjectType(deployment) || - !isObjectType(deploymentRun) || - !isInputObjectType(deployInput) - ) { + if (!query || !isObjectType(appOverview) || !isObjectType(credential)) { throw new Error("Expected App overview GraphQL types."); } const overview = query.getFields().appOverview; - const deploymentStatus = query.getFields().appDeploymentStatus; const controlPlaneOverview = query.getFields().controlPlaneOverview; - const deploy = mutation.getFields().deployApp; - const deleteDeployment = mutation.getFields().deleteAppDeployment; expect(overview).toBeDefined(); expect(String(overview.args.find((arg) => arg.name === "appId")?.type)).toBe("ULID!"); expect(String(overview.args.find((arg) => arg.name === "agentLimit")?.type)).toBe("Int"); expect(String(overview.args.find((arg) => arg.name === "credentialLimit")?.type)).toBe("Int"); - expect(String(appOverview.getFields().deployment.type)).toBe("AppDeployment"); - expect(String(deployment.getFields().latestRun.type)).toBe("AppDeploymentRun"); - expect(String(deploymentRun.getFields().status.type)).toBe("AppDeploymentRunStatus!"); - expect(String(deploymentStatus.type)).toBe("AppDeploymentRun"); - expect(String(deploy.type)).toBe("AppDeploymentRun!"); - expect(String(deleteDeployment.type)).toBe("OperationResult!"); - expect(String(deployInput.getFields().repoUrl.type)).toBe("String!"); + expect(Object.keys(appOverview.getFields()).toSorted()).toEqual([ + "agents", + "app", + "providerCredentials", + ]); expect(controlPlaneOverview).toBeDefined(); expect(String(controlPlaneOverview.args.find((arg) => arg.name === "appLimit")?.type)).toBe( "Int", @@ -250,9 +146,8 @@ describe("App overview", () => { updatedAt: 2, }); await insertOverviewCredentialMetadata(fixture); - const deploymentFixture = await insertOverviewDeploymentMetadata(fixture); - const overview = await getAppOverview(fixture.bindings, fixture.viewer, { + const overview = await getAppOverview(fixture.bindings.DB, fixture.viewer, { agentLimit: 1, appId: fixture.ids.appId, credentialLimit: 10, @@ -262,18 +157,6 @@ describe("App overview", () => { id: fixture.ids.appId, name: "Default App", }); - expect(overview.deployment).toMatchObject({ - latestRun: { - liveUrl: deploymentFixture.liveUrl, - status: "success", - targetKind: "cloudflare_pages", - }, - liveUrl: deploymentFixture.liveUrl, - plannedUrl: deploymentFixture.liveUrl, - repoName: "awire", - repoOwner: "samzong", - }); - expect(overview.boundAgents).toEqual([]); expect(overview.agents).toMatchObject({ hasMore: true, limit: 1, @@ -314,116 +197,11 @@ describe("App overview", () => { ]); }); - test("keeps bound agents from the latest parsed deployment plan during a new active run", async () => { - const fixture = await createApiTestFixture(); - await insertOverviewDeploymentMetadata(fixture); - await insertOverviewAgent(fixture, { - id: "01J000000000000000000000F4", - name: "quizmaster", - updatedAt: 2, - }); - - await fixture.database - .prepare("UPDATE app_deployment_run SET plan_json = ? WHERE id = ?") - .bind( - JSON.stringify({ - agentBindings: [{ env: "QUIZ_THREAD_URL", expose: "public_thread", name: "quizmaster" }], - }), - OVERVIEW_DEPLOYMENT_RUN_ID, - ) - .run(); - - await fixture.database - .prepare( - `INSERT INTO app_deployment_run ( - app_id, - created_at, - deployment_id, - error_code, - error_message, - id, - source_branch, - source_commit_sha, - status, - target_kind, - updated_at, - url - ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`, - ) - .bind( - fixture.ids.appId, - 3, - OVERVIEW_DEPLOYMENT_ID, - null, - null, - "01J000000000000000000000E2", - "main", - "def456", - "preparing", - null, - 4, - null, - ) - .run(); - - await fixture.database - .prepare( - `INSERT INTO api_command ( - attempt_count, - claim_expires_at, - claim_owner, - completed_at, - created_at, - dedupe_key, - id, - kind, - last_error_code, - last_error_message, - payload_json, - status, - updated_at - ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`, - ) - .bind( - 0, - null, - null, - null, - 3, - createAppDeploymentRunDispatchDedupeKey("01J000000000000000000000E2"), - "01J000000000000000000000E3", - "app_deployment_run_dispatch", - null, - null, - JSON.stringify({ appDeploymentRunId: "01J000000000000000000000E2" }), - "queued", - 3, - ) - .run(); - - const overview = await getAppOverview(fixture.bindings, fixture.viewer, { - appId: fixture.ids.appId, - }); - - expect(overview.deployment?.latestRun).toMatchObject({ - id: "01J000000000000000000000E2", - status: "preparing", - }); - expect(overview.boundAgents).toEqual([ - { - agentId: "01J000000000000000000000F4", - envVar: "QUIZ_THREAD_URL", - expose: "public_thread", - name: "quizmaster", - }, - ]); - }); - test("returns current-user control-plane overview for generated CLI list flows", async () => { const fixture = await createApiTestFixture(); await insertOverviewCredentialMetadata(fixture); - const overview = await getControlPlaneOverview(fixture.bindings, fixture.viewer, { + const overview = await getControlPlaneOverview(fixture.bindings.DB, fixture.viewer, { agentLimit: 10, appLimit: 10, credentialLimit: 10, @@ -443,7 +221,6 @@ describe("App overview", () => { id: fixture.ids.appId, name: "Default App", }, - deployment: null, agents: { hasMore: false, limit: 10, @@ -460,7 +237,7 @@ describe("App overview", () => { const fixture = await createApiTestFixture(); await expect( - getAppOverview(fixture.bindings, makeForeignViewer(), { + getAppOverview(fixture.bindings.DB, makeForeignViewer(), { appId: fixture.ids.appId, }), ).rejects.toThrow("You do not have permission"); @@ -470,7 +247,7 @@ describe("App overview", () => { const fixture = await createApiTestFixture(); await expect( - getAppOverview(fixture.bindings, fixture.viewer, { + getAppOverview(fixture.bindings.DB, fixture.viewer, { agentLimit: 0, appId: fixture.ids.appId, }), diff --git a/apps/api/tests/bound-agent-body-limit.test.ts b/apps/api/tests/bound-agent-body-limit.test.ts deleted file mode 100644 index 532f79ce..00000000 --- a/apps/api/tests/bound-agent-body-limit.test.ts +++ /dev/null @@ -1,35 +0,0 @@ -import { describe, expect, test } from "bun:test"; - -import { PUBLIC_THREAD_JSON_BODY_MAX_BYTES } from "@mosoo/contracts/public-api"; - -import { readBoundAgentCallRequestBody } from "../src/adapters/http/routes/public-thread-api-request"; -import { PublicApiError } from "../src/modules/public-api/public-api-errors"; - -function contextFor(request: Request): { req: { raw: Request } } { - return { req: { raw: request } }; -} - -describe("readBoundAgentCallRequestBody", () => { - test("rejects a body whose Content-Length exceeds the public-API cap", async () => { - const request = new Request("https://api.test/api/v1/bound/token", { - body: '{"message":"hi"}', - headers: { "content-length": String(PUBLIC_THREAD_JSON_BODY_MAX_BYTES + 1) }, - method: "POST", - }); - - await expect(readBoundAgentCallRequestBody(contextFor(request))).rejects.toBeInstanceOf( - PublicApiError, - ); - }); - - test("parses a well-formed body within the cap", async () => { - const request = new Request("https://api.test/api/v1/bound/token", { - body: JSON.stringify({ message: "hello" }), - method: "POST", - }); - - await expect(readBoundAgentCallRequestBody(contextFor(request))).resolves.toEqual({ - message: "hello", - }); - }); -}); diff --git a/apps/api/tests/bound-agent-idempotency.e2e.test.ts b/apps/api/tests/bound-agent-idempotency.e2e.test.ts deleted file mode 100644 index 10ca1712..00000000 --- a/apps/api/tests/bound-agent-idempotency.e2e.test.ts +++ /dev/null @@ -1,858 +0,0 @@ -import { describe, expect, test } from "bun:test"; - -import { createPlatformId, parsePlatformId } from "@mosoo/id"; -import type { AgentDeploymentVersionId, SessionId, SessionMessageId } from "@mosoo/id"; -import { Hono } from "hono"; - -import { registerPublicApiRoute } from "../src/adapters/http/routes/public-api-route"; -import type { ApiCommandMessage } from "../src/modules/api-command/application/api-command-message"; -import { getAccountViewer } from "../src/modules/auth/application/public-api-caller.service"; -import { createBoundAgentThreadAndWait } from "../src/modules/public-api/app-agent-bound-ask.service"; -import { - beginBoundAgentCallIdempotency, - hashBoundAgentCallIdempotencyBody, - hashBoundAgentCallIdempotencySubject, -} from "../src/modules/public-api/app-agent-bound-idempotency.service"; -import { mintAppAgentCapabilityToken } from "../src/modules/public-api/app-agent-capability"; -import type { AppAgentCapabilityClaims } from "../src/modules/public-api/app-agent-capability"; -import { queueSessionRun } from "../src/modules/runtime/application/session-run.service"; -import type { ApiBindings, ApiGatewayEnvironment } from "../src/platform/cloudflare/worker-types"; -import { - PUBLIC_API_TEST_IDS, - createApiCommandQueueStub, - createPublicHttpContractDatabase, - createPublicHttpTestBindings, - createTestExecutionContext, - nowMsForTest, -} from "./helpers/public-api-http-test-fixture"; -import type { ApiCommandQueueStub, SqliteD1Database } from "./helpers/public-api-http-test-fixture"; - -const DEPLOYMENT_ID = "01J0000000000000000000000D"; -const DEPLOYMENT_RUN_ID = "01J0000000000000000000000R"; - -interface DispatchCommandPayload { - session: { id: string }; - sessionRunId: string; -} - -interface DurableCounts { - apiCommand: number; - idempotency: number; - run: number; - session: number; -} - -function createBoundAgentRouteTestApp(): Hono { - const app = new Hono(); - const publicApi = new Hono(); - - registerPublicApiRoute(publicApi); - app.route("/api", publicApi); - return app; -} - -function capabilityClaims( - overrides: Partial = {}, -): AppAgentCapabilityClaims { - return { - agentId: PUBLIC_API_TEST_IDS.agent, - appId: PUBLIC_API_TEST_IDS.app, - binding: { - env: "MOSOO_PUBLIC_AGENT", - expose: "public_thread", - name: "Public API Agent", - }, - deploymentId: DEPLOYMENT_ID, - deploymentRunId: DEPLOYMENT_RUN_ID, - exp: Date.now() + 60_000, - ...overrides, - }; -} - -async function insertDeploymentAuthority( - database: SqliteD1Database, - bindings: AppAgentCapabilityClaims["binding"][], -): Promise { - database.execute(` - CREATE TABLE app_deployment ( - app_id text NOT NULL, - deleted_at integer, - id text PRIMARY KEY NOT NULL - ); - - CREATE TABLE app_deployment_run ( - app_id text NOT NULL, - deployment_id text NOT NULL, - id text PRIMARY KEY NOT NULL, - plan_json text, - status text NOT NULL - ); - - CREATE INDEX app_deployment_run_deployment_id_idx - ON app_deployment_run (deployment_id, id); - `); - - await database - .prepare("INSERT INTO app_deployment (app_id, deleted_at, id) VALUES (?, NULL, ?)") - .bind(PUBLIC_API_TEST_IDS.app, DEPLOYMENT_ID) - .run(); - await database - .prepare( - "INSERT INTO app_deployment_run (app_id, deployment_id, id, plan_json, status) VALUES (?, ?, ?, ?, 'success')", - ) - .bind( - PUBLIC_API_TEST_IDS.app, - DEPLOYMENT_ID, - DEPLOYMENT_RUN_ID, - JSON.stringify({ agentBindings: bindings }), - ) - .run(); -} - -function createCompletingApiCommandQueue(database: SqliteD1Database): ApiCommandQueueStub { - const sent: ApiCommandQueueStub["sent"] = []; - - return { - sent, - async send(body: ApiCommandMessage, options): Promise { - sent.push({ - body, - contentType: options?.contentType ?? "json", - delaySeconds: options?.delaySeconds ?? null, - id: `completed-${sent.length + 1}`, - }); - - const command = await database - .prepare("SELECT payload_json AS payloadJson FROM api_command WHERE id = ?") - .bind(body.commandId) - .first<{ payloadJson: string }>(); - - if (command === null) { - throw new Error("Queued API command is missing from the durable ledger."); - } - - const payload = JSON.parse(command.payloadJson) as DispatchCommandPayload; - const timestampMs = nowMsForTest() + sent.length; - - await database - .prepare( - `UPDATE session_run - SET status = 'completed', - completed_at = ?, - status_changed_at = ?, - status_event = 'run.complete', - status_seq = status_seq + 1, - status_source = 'driver', - updated_at = ? - WHERE id = ?`, - ) - .bind(timestampMs, timestampMs, timestampMs, payload.sessionRunId) - .run(); - await database - .prepare( - `UPDATE session - SET status = 'IDLE', - status_seq = status_seq + 1, - message_seq_cursor = message_seq_cursor + 1, - last_message_at = ?, - updated_at = ? - WHERE id = ?`, - ) - .bind(timestampMs, timestampMs, payload.session.id) - .run(); - - const session = await database - .prepare("SELECT message_seq_cursor AS seq FROM session WHERE id = ?") - .bind(payload.session.id) - .first<{ seq: number }>(); - - if (session === null) { - throw new Error("Queued Session is missing."); - } - - await database - .prepare( - `INSERT INTO session_message ( - content_text, - created_at, - created_by_account_id, - id, - plan_json, - role, - segments_json, - seq, - session_id, - session_run_id - ) VALUES (?, ?, ?, ?, NULL, 'assistant', NULL, ?, ?, ?)`, - ) - .bind( - "The original bound request completed.", - timestampMs, - PUBLIC_API_TEST_IDS.ownerAccount, - createPlatformId(), - session.seq, - payload.session.id, - payload.sessionRunId, - ) - .run(); - await database - .prepare( - `UPDATE api_command - SET status = 'completed', - completed_at = ?, - last_error_code = NULL, - last_error_message = NULL, - updated_at = ? - WHERE id = ?`, - ) - .bind(timestampMs, timestampMs, body.commandId) - .run(); - }, - }; -} - -function failFirstMatchingStatement(database: D1Database, pattern: RegExp): D1Database { - let failed = false; - - function wrapStatement(statement: D1PreparedStatement, query: string): D1PreparedStatement { - const shouldFail = pattern.test(query); - - return new Proxy(statement, { - get(target, property, receiver) { - if (property === "bind") { - return (...values: unknown[]) => wrapStatement(target.bind(...values), query); - } - - if ( - shouldFail && - !failed && - (property === "all" || property === "first" || property === "raw" || property === "run") - ) { - return async () => { - failed = true; - throw new Error(`Injected bound admission failure for: ${query}`); - }; - } - - return Reflect.get(target, property, receiver); - }, - }); - } - - return { - batch: database.batch.bind(database), - prepare: (query) => wrapStatement(database.prepare(query), query), - } as D1Database; -} - -async function requestBoundAgent(input: { - claims: AppAgentCapabilityClaims; - database: D1Database; - idempotencyKey?: string; - message: string; - queue: ApiCommandQueueStub; -}): Promise { - const bindings = createPublicHttpTestBindings(input.database, { - apiCommandQueue: input.queue, - }) as ApiBindings; - const token = await mintAppAgentCapabilityToken( - bindings.RUNTIME_ACTION_TOKEN_SECRET, - input.claims, - ); - const headers = new Headers({ "Content-Type": "application/json" }); - - if (input.idempotencyKey !== undefined) { - headers.set("Idempotency-Key", input.idempotencyKey); - } - - return createBoundAgentRouteTestApp().request( - new Request(`https://api.example.com/api/v1/bound/${token}`, { - body: JSON.stringify({ message: input.message }), - headers, - method: "POST", - }), - undefined, - bindings, - createTestExecutionContext(), - ); -} - -async function readDurableCounts(database: SqliteD1Database): Promise { - const [session, run, apiCommand, idempotency] = await Promise.all( - ["session", "session_run", "api_command", "bound_agent_call_idempotency_key"].map((table) => - database.prepare(`SELECT COUNT(*) AS count FROM ${table}`).first<{ count: number }>(), - ), - ); - - if (session === null || run === null || apiCommand === null || idempotency === null) { - throw new Error("Durable count query did not return a row."); - } - - return { - apiCommand: apiCommand.count, - idempotency: idempotency.count, - run: run.count, - session: session.count, - }; -} - -async function withProviderProbeMock(operation: () => Promise): Promise { - const originalFetch = globalThis.fetch; - globalThis.fetch = async () => Response.json({ data: [{ id: "gpt-5.4" }] }); - - try { - return await operation(); - } finally { - globalThis.fetch = originalFetch; - } -} - -async function withAcceleratedClock(operation: () => Promise): Promise { - const originalNow = Date.now; - let nowMs = originalNow(); - Date.now = () => { - nowMs += 30_000; - return nowMs; - }; - - try { - return await operation(); - } finally { - Date.now = originalNow; - } -} - -async function createFixture(bindings = [capabilityClaims().binding]) { - const database = await createPublicHttpContractDatabase(); - await insertDeploymentAuthority(database, bindings); - const queue = createCompletingApiCommandQueue(database); - - return { database, queue }; -} - -describe("bound Agent HTTP idempotency", () => { - test("serializes concurrent reservations onto one stable Session identity", async () => { - const database = await createPublicHttpContractDatabase(); - const claims = capabilityClaims(); - const input = { - bodyHash: await hashBoundAgentCallIdempotencyBody("concurrent request"), - idempotencyKey: "concurrent-reservation-327", - subjectHash: await hashBoundAgentCallIdempotencySubject(claims), - }; - - const reservations = await Promise.all([ - beginBoundAgentCallIdempotency(database, input), - beginBoundAgentCallIdempotency(database, input), - ]); - - expect(new Set(reservations.map((reservation) => reservation.reservationId)).size).toBe(1); - expect(new Set(reservations.map((reservation) => reservation.sessionId)).size).toBe(1); - expect(reservations.map((reservation) => reservation.status).toSorted()).toEqual([ - "existing", - "reserved", - ]); - await expect( - database.prepare("SELECT COUNT(*) AS count FROM bound_agent_call_idempotency_key").first(), - ).resolves.toEqual({ count: 1 }); - }); - - test("serializes concurrent HTTP retries onto one Session and Run", async () => { - const { database, queue } = await createFixture(); - - await withProviderProbeMock(async () => { - const requests = await Promise.all([ - requestBoundAgent({ - claims: capabilityClaims(), - database, - idempotencyKey: "concurrent-http-327", - message: "one concurrent logical request", - queue, - }), - requestBoundAgent({ - claims: capabilityClaims(), - database, - idempotencyKey: "concurrent-http-327", - message: "one concurrent logical request", - queue, - }), - ]); - const bodies = await Promise.all(requests.map((response) => response.json())); - - expect(requests.map((response) => response.status)).toEqual([200, 200]); - expect(bodies[1]).toEqual(bodies[0]); - }); - - await expect(readDurableCounts(database)).resolves.toEqual({ - apiCommand: 1, - idempotency: 1, - run: 1, - session: 1, - }); - expect(queue.sent).toHaveLength(1); - }); - - test("does not duplicate admission when the original service call times out", async () => { - const { database } = await createFixture(); - const queue = createApiCommandQueueStub(); - const bindings = createPublicHttpTestBindings(database, { - apiCommandQueue: queue, - }) as ApiBindings; - const claims = capabilityClaims({ exp: Date.now() + 24 * 60 * 60 * 1000 }); - const token = await mintAppAgentCapabilityToken(bindings.RUNTIME_ACTION_TOKEN_SECRET, claims); - const request = { - bindings, - executionContext: null, - idempotencyKey: "timeout-retry-327", - input: { message: "same logical request" }, - requestUrl: `https://api.example.com/api/v1/bound/${token}`, - token, - } as const; - - await withProviderProbeMock(() => - withAcceleratedClock(async () => { - await expect(createBoundAgentThreadAndWait(request)).rejects.toMatchObject({ - code: "deployment_agent_call_timeout", - status: 504, - }); - await expect(createBoundAgentThreadAndWait(request)).rejects.toMatchObject({ - code: "deployment_agent_call_timeout", - status: 504, - }); - }), - ); - - await expect(readDurableCounts(database)).resolves.toEqual({ - apiCommand: 1, - idempotency: 1, - run: 1, - session: 1, - }); - expect(queue.sent).toHaveLength(1); - }); - - test("recovers the original Session and Run after an ambiguous HTTP result", async () => { - const { database, queue } = await createFixture(); - - await withProviderProbeMock(async () => { - const first = await requestBoundAgent({ - claims: capabilityClaims(), - database, - idempotencyKey: "logical-request-327", - message: "same logical request", - queue, - }); - const firstBody = await first.json(); - - expect(first.status).toBe(200); - - // Discarding the first result models a response lost after durable admission. - const retried = await requestBoundAgent({ - claims: capabilityClaims(), - database, - idempotencyKey: "logical-request-327", - message: "same logical request", - queue, - }); - - expect(retried.status).toBe(200); - expect(await retried.json()).toEqual(firstBody); - }); - - await expect(readDurableCounts(database)).resolves.toEqual({ - apiCommand: 1, - idempotency: 1, - run: 1, - session: 1, - }); - expect(queue.sent).toHaveLength(1); - const reservation = await database - .prepare( - `SELECT id, session_id AS sessionId - FROM bound_agent_call_idempotency_key`, - ) - .first<{ id: string; sessionId: string }>(); - const event = await database - .prepare("SELECT source_event_id AS sourceEventId FROM session_event ORDER BY seq LIMIT 1") - .first<{ sourceEventId: string }>(); - - expect(reservation).not.toBeNull(); - expect(event?.sourceEventId).toBe(reservation?.id); - }); - - test("recovers the first Run without a binding or event receipt after a later Run", async () => { - const { database, queue } = await createFixture(); - let firstBody: unknown; - - await withProviderProbeMock(async () => { - const first = await requestBoundAgent({ - claims: capabilityClaims(), - database, - idempotencyKey: "original-run-327", - message: "original logical request", - queue, - }); - firstBody = await first.json(); - expect(first.status).toBe(200); - - const reservation = await database - .prepare( - `SELECT run_id AS runId, session_id AS sessionId - FROM bound_agent_call_idempotency_key`, - ) - .first<{ runId: string; sessionId: string }>(); - const viewer = await getAccountViewer(database, PUBLIC_API_TEST_IDS.ownerAccount); - - if (reservation === null || viewer === null) { - throw new Error("Bound idempotency recovery fixture is incomplete."); - } - - // Model interruption before both recovery links become durable. The - // reserved Session itself must still prevent a later Run from becoming - // the replay target for the original key. - await database.prepare("UPDATE bound_agent_call_idempotency_key SET run_id = NULL").run(); - await database.prepare("UPDATE session_event SET source_event_id = id").run(); - - const later = await queueSessionRun({ - bindings: createPublicHttpTestBindings(database, { - apiCommandQueue: queue, - }) as ApiBindings, - executionContext: null, - input: { - accessViewer: viewer, - attachmentIds: [], - clientRequestId: null, - prompt: "intentional later Run", - session: { - agent_id: PUBLIC_API_TEST_IDS.agent, - app_id: PUBLIC_API_TEST_IDS.app, - deployment_version_id: parsePlatformId( - PUBLIC_API_TEST_IDS.deployment, - "fixture deployment version", - ), - deployment_version_number: 1, - id: parsePlatformId(reservation.sessionId, "bound Session"), - model: "gpt-5.4", - provider: "openai", - runtime_id: "openai-runtime", - }, - }, - requestUrl: "https://api.example.com/api/graphql", - viewer, - }); - - expect(later.run.id).not.toBe(reservation.runId); - - const retried = await requestBoundAgent({ - claims: capabilityClaims(), - database, - idempotencyKey: "original-run-327", - message: "original logical request", - queue, - }); - - expect(retried.status).toBe(200); - expect(await retried.json()).toEqual(firstBody); - await expect( - database.prepare("SELECT run_id AS runId FROM bound_agent_call_idempotency_key").first(), - ).resolves.toEqual({ runId: reservation.runId }); - }); - - await expect(readDurableCounts(database)).resolves.toEqual({ - apiCommand: 2, - idempotency: 1, - run: 2, - session: 1, - }); - expect(queue.sent).toHaveLength(2); - }); - - test("fails closed when one key is reused for a different body", async () => { - const { database, queue } = await createFixture(); - - await withProviderProbeMock(async () => { - const first = await requestBoundAgent({ - claims: capabilityClaims(), - database, - idempotencyKey: "body-conflict-327", - message: "first body", - queue, - }); - const conflict = await requestBoundAgent({ - claims: capabilityClaims(), - database, - idempotencyKey: "body-conflict-327", - message: "different body", - queue, - }); - - expect(first.status).toBe(200); - expect(conflict.status).toBe(409); - expect(await conflict.json()).toEqual({ - error: { - code: "idempotency_conflict", - message: "Idempotency-Key was already used for a different request.", - }, - }); - }); - - await expect(readDurableCounts(database)).resolves.toEqual({ - apiCommand: 1, - idempotency: 1, - run: 1, - session: 1, - }); - }); - - test("scopes the same key to the verified deployment binding identity", async () => { - const alternateClaims = capabilityClaims({ - binding: { - env: "MOSOO_SECOND_AGENT", - expose: "public_thread", - name: "Public API Agent", - }, - }); - const { database, queue } = await createFixture([ - capabilityClaims().binding, - alternateClaims.binding, - ]); - - await withProviderProbeMock(async () => { - const first = await requestBoundAgent({ - claims: capabilityClaims(), - database, - idempotencyKey: "binding-scoped-327", - message: "same body", - queue, - }); - const second = await requestBoundAgent({ - claims: alternateClaims, - database, - idempotencyKey: "binding-scoped-327", - message: "same body", - queue, - }); - - expect(first.status).toBe(200); - expect(second.status).toBe(200); - }); - - await expect(readDurableCounts(database)).resolves.toEqual({ - apiCommand: 2, - idempotency: 2, - run: 2, - session: 2, - }); - }); - - test("retries the same reserved Session after Session creation fails", async () => { - const { database, queue } = await createFixture(); - const injectedDatabase = failFirstMatchingStatement( - database, - /\bINSERT\s+INTO\s+"session"(?:\s|\()/iu, - ); - - await withProviderProbeMock(async () => { - const failed = await requestBoundAgent({ - claims: capabilityClaims(), - database: injectedDatabase, - idempotencyKey: "session-recovery-327", - message: "recover after Session failure", - queue, - }); - - expect(failed.status).toBe(500); - await expect(readDurableCounts(database)).resolves.toEqual({ - apiCommand: 0, - idempotency: 1, - run: 0, - session: 0, - }); - - const retried = await requestBoundAgent({ - claims: capabilityClaims(), - database: injectedDatabase, - idempotencyKey: "session-recovery-327", - message: "recover after Session failure", - queue, - }); - - expect(retried.status).toBe(200); - }); - - await expect(readDurableCounts(database)).resolves.toEqual({ - apiCommand: 1, - idempotency: 1, - run: 1, - session: 1, - }); - }); - - test("resumes the reserved Session after Run admission fails", async () => { - const { database, queue } = await createFixture(); - const injectedDatabase = failFirstMatchingStatement( - database, - /\bINSERT\s+INTO\s+"session_run"(?:\s|\()/iu, - ); - - await withProviderProbeMock(async () => { - const failed = await requestBoundAgent({ - claims: capabilityClaims(), - database: injectedDatabase, - idempotencyKey: "run-recovery-327", - message: "recover after Run failure", - queue, - }); - - expect(failed.status).toBe(500); - await expect(readDurableCounts(database)).resolves.toEqual({ - apiCommand: 0, - idempotency: 1, - run: 0, - session: 1, - }); - - const retried = await requestBoundAgent({ - claims: capabilityClaims(), - database: injectedDatabase, - idempotencyKey: "run-recovery-327", - message: "recover after Run failure", - queue, - }); - - expect(retried.status).toBe(200); - }); - - await expect(readDurableCounts(database)).resolves.toEqual({ - apiCommand: 1, - idempotency: 1, - run: 1, - session: 1, - }); - }); - - test("repairs the original Run binding after its first persistence attempt fails", async () => { - const { database, queue } = await createFixture(); - const injectedDatabase = failFirstMatchingStatement( - database, - /\bUPDATE\s+"bound_agent_call_idempotency_key"\s+SET\b/iu, - ); - - await withProviderProbeMock(async () => { - const failed = await requestBoundAgent({ - claims: capabilityClaims(), - database: injectedDatabase, - idempotencyKey: "run-binding-recovery-327", - message: "recover the accepted Run binding", - queue, - }); - - expect(failed.status).toBe(500); - await expect(readDurableCounts(database)).resolves.toEqual({ - apiCommand: 1, - idempotency: 1, - run: 1, - session: 1, - }); - await expect( - database.prepare("SELECT run_id AS runId FROM bound_agent_call_idempotency_key").first(), - ).resolves.toEqual({ runId: null }); - - const retried = await requestBoundAgent({ - claims: capabilityClaims(), - database: injectedDatabase, - idempotencyKey: "run-binding-recovery-327", - message: "recover the accepted Run binding", - queue, - }); - - expect(retried.status).toBe(200); - }); - - await expect(readDurableCounts(database)).resolves.toEqual({ - apiCommand: 1, - idempotency: 1, - run: 1, - session: 1, - }); - expect(queue.sent).toHaveLength(1); - expect( - await database - .prepare("SELECT run_id AS runId FROM bound_agent_call_idempotency_key") - .first<{ runId: string | null }>(), - ).toEqual({ - runId: expect.any(String), - }); - }); - - test("rechecks capability revocation before recovering an existing key", async () => { - const { database, queue } = await createFixture(); - - await withProviderProbeMock(async () => { - const first = await requestBoundAgent({ - claims: capabilityClaims(), - database, - idempotencyKey: "revoked-retry-327", - message: "authorize every retry", - queue, - }); - expect(first.status).toBe(200); - - await database - .prepare("UPDATE app_deployment SET deleted_at = ? WHERE id = ?") - .bind(Date.now(), DEPLOYMENT_ID) - .run(); - - const revoked = await requestBoundAgent({ - claims: capabilityClaims(), - database, - idempotencyKey: "revoked-retry-327", - message: "authorize every retry", - queue, - }); - - expect(revoked.status).toBe(409); - expect(await revoked.json()).toEqual({ - error: { - code: "agent_not_published", - message: "This capability is no longer authorized for the active deployment.", - }, - }); - }); - - await expect(readDurableCounts(database)).resolves.toEqual({ - apiCommand: 1, - idempotency: 1, - run: 1, - session: 1, - }); - }); - - test("preserves the existing non-idempotent behavior when no key is supplied", async () => { - const { database, queue } = await createFixture(); - - await withProviderProbeMock(async () => { - const first = await requestBoundAgent({ - claims: capabilityClaims(), - database, - message: "intentional call one", - queue, - }); - const second = await requestBoundAgent({ - claims: capabilityClaims(), - database, - message: "intentional call two", - queue, - }); - - expect(first.status).toBe(200); - expect(second.status).toBe(200); - }); - - await expect(readDurableCounts(database)).resolves.toEqual({ - apiCommand: 2, - idempotency: 0, - run: 2, - session: 2, - }); - }); -}); diff --git a/apps/api/tests/bound-capability-fixtures.ts b/apps/api/tests/bound-capability-fixtures.ts deleted file mode 100644 index b4bf29e4..00000000 --- a/apps/api/tests/bound-capability-fixtures.ts +++ /dev/null @@ -1,161 +0,0 @@ -import type { Hono } from "hono"; - -import { mintAppAgentCapabilityToken } from "../src/modules/public-api/app-agent-capability"; -import type { AppAgentCapabilityClaims } from "../src/modules/public-api/app-agent-capability"; -import type { ApiBindings } from "../src/platform/cloudflare/worker-types"; -import { - PUBLIC_API_TEST_IDS, - createPublicHttpTestBindings, -} from "./helpers/public-api-http-test-fixture"; -import type { SqliteD1Database } from "./helpers/public-api-http-test-fixture"; -import { requestPublicApiWithBindings } from "./public-thread-api-fixtures"; - -export const BOUND_DEPLOYMENT_ID = "01J0000000000000000000000D"; -export const BOUND_DEPLOYMENT_RUN_ID = "01J0000000000000000000000R"; -export const BOUND_REPLACEMENT_DEPLOYMENT_RUN_ID = "01J0000000000000000000000S"; -export const BOUND_OTHER_DEPLOYMENT_ID = "01J0000000000000000000000E"; -export const BOUND_OTHER_DEPLOYMENT_RUN_ID = "01J0000000000000000000000T"; - -export const BOUND_BINDING = { - env: "MOSOO_AGENT_URL", - expose: "public_thread", - name: "Public API Agent", -} as const; - -export function boundCapabilityClaims( - overrides: Partial = {}, -): AppAgentCapabilityClaims { - return { - agentId: PUBLIC_API_TEST_IDS.agent, - appId: PUBLIC_API_TEST_IDS.app, - binding: { ...BOUND_BINDING }, - deploymentId: BOUND_DEPLOYMENT_ID, - deploymentRunId: BOUND_DEPLOYMENT_RUN_ID, - exp: Date.now() + 60_000, - ...overrides, - }; -} - -/** - * The minimal Deployment authority tables the capability checks read. Mirrors - * the production columns the authority service touches; the public HTTP core - * schema does not include them. - */ -export function createBoundDeploymentAuthoritySchema(database: SqliteD1Database): void { - database.execute(` - CREATE TABLE app_deployment ( - app_id text NOT NULL, - deleted_at integer, - id text PRIMARY KEY NOT NULL - ); - - CREATE TABLE app_deployment_run ( - app_id text NOT NULL, - deployment_id text NOT NULL, - id text PRIMARY KEY NOT NULL, - plan_json text, - status text NOT NULL - ); - - CREATE INDEX app_deployment_run_deployment_id_idx - ON app_deployment_run (deployment_id, id); - `); -} - -export async function insertBoundDeployment( - database: SqliteD1Database, - input: { - agentBindings?: unknown[]; - deletedAt?: number | null; - deploymentId?: string; - deploymentRunId?: string; - } = {}, -): Promise { - const deploymentId = input.deploymentId ?? BOUND_DEPLOYMENT_ID; - - await database - .prepare("INSERT INTO app_deployment (app_id, deleted_at, id) VALUES (?, ?, ?)") - .bind(PUBLIC_API_TEST_IDS.app, input.deletedAt ?? null, deploymentId) - .run(); - await insertBoundDeploymentRun(database, { - agentBindings: input.agentBindings ?? [BOUND_BINDING], - deploymentId, - deploymentRunId: input.deploymentRunId ?? BOUND_DEPLOYMENT_RUN_ID, - }); -} - -export async function insertBoundDeploymentRun( - database: SqliteD1Database, - input: { - agentBindings: unknown[]; - deploymentId: string; - deploymentRunId: string; - status?: string; - }, -): Promise { - await database - .prepare( - "INSERT INTO app_deployment_run (app_id, deployment_id, id, plan_json, status) VALUES (?, ?, ?, ?, ?)", - ) - .bind( - PUBLIC_API_TEST_IDS.app, - input.deploymentId, - input.deploymentRunId, - JSON.stringify({ agentBindings: input.agentBindings }), - input.status ?? "success", - ) - .run(); -} - -export async function deleteBoundDeployment( - database: SqliteD1Database, - deploymentId = BOUND_DEPLOYMENT_ID, -): Promise { - await database - .prepare("UPDATE app_deployment SET deleted_at = ? WHERE id = ?") - .bind(Date.now(), deploymentId) - .run(); -} - -export async function mintBoundCapabilityToken( - bindings: ApiBindings, - claims: AppAgentCapabilityClaims = boundCapabilityClaims(), -): Promise { - return mintAppAgentCapabilityToken(bindings.RUNTIME_ACTION_TOKEN_SECRET, claims); -} - -export function boundCapabilityUrl(token: string, path = ""): string { - return `https://api.example.com/api/v1/bound/${token}${path}`; -} - -export interface BoundCapabilityClient { - bindings: ApiBindings; - request: (path: string, init?: RequestInit) => Promise; - token: string; -} - -export async function createBoundCapabilityClient(input: { - app: Hono; - bindings: ApiBindings; - claims?: AppAgentCapabilityClaims; -}): Promise { - const token = await mintBoundCapabilityToken(input.bindings, input.claims); - - return { - bindings: input.bindings, - request: (path, init) => - requestPublicApiWithBindings( - input.app, - new Request(boundCapabilityUrl(token, path), init), - input.bindings, - ), - token, - }; -} - -export function createBoundTestBindings( - database: SqliteD1Database, - options: Parameters[1] = {}, -): ApiBindings { - return createPublicHttpTestBindings(database, options) as ApiBindings; -} diff --git a/apps/api/tests/bound-capability-public-thread-api.e2e.test.ts b/apps/api/tests/bound-capability-public-thread-api.e2e.test.ts deleted file mode 100644 index 22b9c7cb..00000000 --- a/apps/api/tests/bound-capability-public-thread-api.e2e.test.ts +++ /dev/null @@ -1,638 +0,0 @@ -import { describe, expect, test } from "bun:test"; - -import { sessionRunsTable, sessionsTable } from "@mosoo/db"; -import { eq } from "drizzle-orm"; - -import { insertSessionMessage } from "../src/modules/sessions/infrastructure/session-message-store.repository"; -import { - BOUND_DEPLOYMENT_ID, - BOUND_DEPLOYMENT_RUN_ID, - BOUND_OTHER_DEPLOYMENT_ID, - BOUND_OTHER_DEPLOYMENT_RUN_ID, - BOUND_REPLACEMENT_DEPLOYMENT_RUN_ID, - BOUND_BINDING, - boundCapabilityClaims, - createBoundCapabilityClient, - createBoundDeploymentAuthoritySchema, - createBoundTestBindings, - deleteBoundDeployment, - insertBoundDeployment, - insertBoundDeploymentRun, -} from "./bound-capability-fixtures"; -import { - PublicApiMemoryFileBucket, - PUBLIC_API_TEST_IDS, - TOKENS, - createPublicHttpContractDatabase, -} from "./helpers/public-api-http-test-fixture"; -import type { SqliteD1Database } from "./helpers/public-api-http-test-fixture"; -import { - bearer, - createPublicThreadApiTestApp, - expectArray, - expectRecord, - expectString, - readJson, - requestPublicApiWithBindings, - withProviderProbeMock, -} from "./public-thread-api-fixtures"; - -const ATTACHMENT_BODY = "Avatar bytes.\n"; -const ARTIFACT_BODY = "PK codex-pet.zip"; -const FINAL_OUTPUT_TEXT = "Your pet is ready: outputs/codex-pet.zip"; - -interface BoundSurface { - bindings: ReturnType; - bucket: PublicApiMemoryFileBucket; - database: SqliteD1Database; - ownerRequest: (path: string, init?: RequestInit) => Promise; - request: (path: string, init?: RequestInit) => Promise; - token: string; -} - -async function createBoundSurface(claims = boundCapabilityClaims()): Promise { - const database = await createPublicHttpContractDatabase(); - createBoundDeploymentAuthoritySchema(database); - await insertBoundDeployment(database); - - const app = createPublicThreadApiTestApp(); - const bucket = new PublicApiMemoryFileBucket(); - const bindings = createBoundTestBindings(database, { - fileBucket: bucket as unknown as R2Bucket, - }); - const client = await createBoundCapabilityClient({ app, bindings, claims }); - - return { - bindings, - bucket, - database, - ownerRequest: (path, init) => - requestPublicApiWithBindings( - app, - new Request(`https://api.example.com/api/v1${path}`, { - ...init, - headers: { - ...Object.fromEntries(new Headers(init?.headers)), - Authorization: bearer(TOKENS.owner), - }, - }), - bindings, - ), - request: client.request, - token: client.token, - }; -} - -function attachmentForm(name = "avatar.png", body = ATTACHMENT_BODY): FormData { - const formData = new FormData(); - formData.set("file", new File([new TextEncoder().encode(body)], name, { type: "image/png" })); - return formData; -} - -function createThreadBody(fileId: string, userId = "end-user-42"): string { - return JSON.stringify({ - input: { - content: [{ text: "Turn the attached avatar into a pet.", type: "text" }], - type: "user.message", - }, - resources: [{ file_id: fileId, type: "file" }], - userId, - }); -} - -async function uploadAttachment(surface: BoundSurface): Promise { - const response = await surface.request("/files", { body: attachmentForm(), method: "POST" }); - - expect(response.status).toBe(201); - const file = expectRecord(expectRecord(await readJson(response))["file"]); - expect(file).toMatchObject({ name: "avatar.png", size: ATTACHMENT_BODY.length }); - - return expectString(file["id"]); -} - -async function createThread( - surface: BoundSurface, - fileId: string, -): Promise<{ runId: string; threadId: string }> { - const response = await surface.request("/threads", { - body: createThreadBody(fileId), - headers: { "Content-Type": "application/json" }, - method: "POST", - }); - - expect(response.status).toBe(201); - const payload = await readJson(response); - const run = expectRecord(payload["run"]); - expect(["queued", "running"]).toContain(run["status"]); - - return { - runId: expectString(run["id"]), - threadId: expectString(expectRecord(payload["thread"])["id"]), - }; -} - -async function readRunProvenance( - database: SqliteD1Database, - runId: string, -): Promise | null> { - return database - .prepare( - `SELECT bound_capability_agent_id, bound_capability_app_id, bound_capability_binding_env, - bound_capability_binding_name, bound_capability_deployment_id, - bound_capability_deployment_run_id - FROM session_run - WHERE id = ?`, - ) - .bind(runId) - .first>(); -} - -async function simulateArtifactAndCompletion( - surface: BoundSurface, - input: { runId: string; threadId: string }, -): Promise { - const artifactId = PUBLIC_API_TEST_IDS.fileAlt; - const objectKey = `session/${input.threadId}/artifact/${artifactId}/codex-pet.zip`; - - await surface.database - .prepare( - `INSERT INTO file_record ( - id, scope_kind, scope_id, session_kind, status, name, path, parent_path, object_key, - owner_id, owner_kind, purpose, expires_at, mime_type, size, etag, committed, version, - created_by_account_id, created_at, updated_at - ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`, - ) - .bind( - artifactId, - "session", - input.threadId, - "artifact", - "ready", - "codex-pet.zip", - `artifact/${artifactId}/codex-pet.zip`, - `artifact/${artifactId}`, - objectKey, - input.threadId, - "session", - "session_artifact", - null, - "application/zip", - ARTIFACT_BODY.length, - null, - 1, - 1, - PUBLIC_API_TEST_IDS.ownerAccount, - 2, - 2, - ) - .run(); - await surface.bucket.put(objectKey, ARTIFACT_BODY, { - httpMetadata: { contentType: "application/zip" }, - }); - - await insertSessionMessage(surface.database, { - content: FINAL_OUTPUT_TEXT, - createdByAccountId: PUBLIC_API_TEST_IDS.ownerAccount, - role: "assistant", - segments: [{ kind: "text", text: FINAL_OUTPUT_TEXT }], - sessionId: input.threadId, - sessionRunId: input.runId, - }); - await surface.database - .app() - .update(sessionRunsTable) - .set({ - completedAt: 1_150, - errorCode: null, - errorDetailsJson: null, - errorMessage: null, - status: "completed", - updatedAt: 1_150, - }) - .where(eq(sessionRunsTable.id, input.runId)) - .run(); - await surface.database - .app() - .update(sessionsTable) - .set({ lastRunId: input.runId, status: "IDLE", updatedAt: 1_150 }) - .where(eq(sessionsTable.id, input.threadId)) - .run(); - - return artifactId; -} - -describe("bound capability Public Thread API e2e", () => { - test("runs upload -> Thread/Run -> artifact download through the deployment identity", async () => { - const surface = await createBoundSurface(); - - await withProviderProbeMock(async () => { - const fileId = await uploadAttachment(surface); - const draftRow = await surface.database - .prepare( - "SELECT created_by_account_id, owner_id, purpose, scope_kind FROM file_record WHERE id = ?", - ) - .bind(fileId) - .first>(); - expect(draftRow).toEqual({ - created_by_account_id: PUBLIC_API_TEST_IDS.ownerAccount, - owner_id: PUBLIC_API_TEST_IDS.app, - purpose: "app_draft", - scope_kind: "app_draft", - }); - - // A capability only sees a file once it is attached to one of its Threads. - const draftLookup = await surface.request(`/files/${fileId}`); - expect(draftLookup.status).toBe(404); - - const { runId, threadId } = await createThread(surface, fileId); - - const sessionRow = await surface.database - .prepare( - "SELECT agent_id, app_id, creator_account_id, end_user_id, metadata_json FROM session WHERE id = ?", - ) - .bind(threadId) - .first<{ - agent_id: string; - app_id: string; - creator_account_id: string; - end_user_id: string; - metadata_json: string; - }>(); - expect(sessionRow).toMatchObject({ - agent_id: PUBLIC_API_TEST_IDS.agent, - app_id: PUBLIC_API_TEST_IDS.app, - creator_account_id: PUBLIC_API_TEST_IDS.ownerAccount, - end_user_id: "end-user-42", - }); - expect(JSON.parse(sessionRow?.metadata_json ?? "{}")).toEqual({ - public_api: { - created_by: { - binding_env: BOUND_BINDING.env, - binding_name: BOUND_BINDING.name, - deployment_id: BOUND_DEPLOYMENT_ID, - deployment_run_id: BOUND_DEPLOYMENT_RUN_ID, - kind: "deployment_capability", - }, - idempotency_key: null, - source: "public_api", - }, - }); - - expect(await readRunProvenance(surface.database, runId)).toEqual({ - bound_capability_agent_id: PUBLIC_API_TEST_IDS.agent, - bound_capability_app_id: PUBLIC_API_TEST_IDS.app, - bound_capability_binding_env: BOUND_BINDING.env, - bound_capability_binding_name: BOUND_BINDING.name, - bound_capability_deployment_id: BOUND_DEPLOYMENT_ID, - bound_capability_deployment_run_id: BOUND_DEPLOYMENT_RUN_ID, - }); - - const claimedRow = await surface.database - .prepare("SELECT scope_id, scope_kind, session_kind FROM file_record WHERE id = ?") - .bind(fileId) - .first>(); - expect(claimedRow).toEqual({ - scope_id: threadId, - scope_kind: "session", - session_kind: "attachment", - }); - - const pending = await surface.request(`/threads/${threadId}`); - expect(pending.status).toBe(200); - const pendingPayload = await readJson(pending); - expect(expectRecord(pendingPayload["thread"])["id"]).toBe(threadId); - expect(expectRecord(pendingPayload["run"])["id"]).toBe(runId); - expect(expectRecord(pendingPayload["run"])["status"]).not.toBe("completed"); - // Responses never echo the capability token back to the deployed App. - expect(JSON.stringify(pendingPayload)).not.toContain(surface.token); - - const events = await surface.request(`/threads/${threadId}/events`); - expect(events.status).toBe(200); - expectArray((await readJson(events))["events"]); - - const artifactId = await simulateArtifactAndCompletion(surface, { runId, threadId }); - - const completed = await surface.request(`/threads/${threadId}`); - expect(completed.status).toBe(200); - expect(expectRecord((await readJson(completed))["run"])).toMatchObject({ - finalOutput: { text: FINAL_OUTPUT_TEXT }, - id: runId, - status: "completed", - }); - - const listed = await surface.request(`/threads/${threadId}/files`); - expect(listed.status).toBe(200); - const files = expectArray((await readJson(listed))["files"]).map((file) => - expectRecord(file), - ); - expect(files.map((file) => [file["id"], file["kind"], file["name"]])).toEqual( - expect.arrayContaining([ - [fileId, "attachment", "avatar.png"], - [artifactId, "artifact", "codex-pet.zip"], - ]), - ); - - const artifactMetadata = await surface.request(`/files/${artifactId}`); - expect(artifactMetadata.status).toBe(200); - expect(expectRecord((await readJson(artifactMetadata))["file"])).toMatchObject({ - id: artifactId, - name: "codex-pet.zip", - }); - - const download = await surface.request(`/files/${artifactId}/content?disposition=attachment`); - expect(download.status).toBe(200); - expect(download.headers.get("content-type")).toStartWith("application/zip"); - expect(download.headers.get("content-disposition")).toContain('filename="codex-pet.zip"'); - expect(await download.text()).toBe(ARTIFACT_BODY); - - const threads = await surface.request("/threads"); - expect(threads.status).toBe(200); - expect( - expectArray((await readJson(threads))["threads"]).map( - (thread) => expectRecord(thread)["id"], - ), - ).toEqual([threadId]); - - // Continue the Thread: the follow-up Run carries the same provenance. - const followUp = await surface.request(`/threads/${threadId}/events`, { - body: JSON.stringify({ - events: [{ text: "Make the tail longer.", type: "user_message" }], - }), - headers: { "Content-Type": "application/json" }, - method: "POST", - }); - expect(followUp.status).toBe(200); - const followUpEvent = expectRecord( - expectArray(expectRecord(await readJson(followUp))["events"])[0], - ); - const followUpRunId = expectString(expectRecord(followUpEvent["run"])["id"]); - expect(followUpRunId).not.toBe(runId); - expect(await readRunProvenance(surface.database, followUpRunId)).toMatchObject({ - bound_capability_deployment_id: BOUND_DEPLOYMENT_ID, - bound_capability_deployment_run_id: BOUND_DEPLOYMENT_RUN_ID, - }); - }); - }); - - test("keeps the capability inside its App, declared Agent, and Deployment", async () => { - const surface = await createBoundSurface(); - await insertBoundDeployment(surface.database, { - deploymentId: BOUND_OTHER_DEPLOYMENT_ID, - deploymentRunId: BOUND_OTHER_DEPLOYMENT_RUN_ID, - }); - const otherDeployment = await createBoundCapabilityClient({ - app: createPublicThreadApiTestApp(), - bindings: surface.bindings, - claims: boundCapabilityClaims({ - deploymentId: BOUND_OTHER_DEPLOYMENT_ID, - deploymentRunId: BOUND_OTHER_DEPLOYMENT_RUN_ID, - }), - }); - - await withProviderProbeMock(async () => { - const fileId = await uploadAttachment(surface); - const { threadId } = await createThread(surface, fileId); - - // The owner's own Access Token Thread for the same Agent is invisible. - const ownerThread = await surface.ownerRequest( - `/agents/${PUBLIC_API_TEST_IDS.agent}/threads`, - { - body: JSON.stringify({ userId: "owner-customer" }), - headers: { "Content-Type": "application/json" }, - method: "POST", - }, - ); - expect(ownerThread.status).toBe(201); - const ownerThreadId = expectString( - expectRecord(expectRecord(await readJson(ownerThread))["thread"])["id"], - ); - - for (const path of [ - `/threads/${ownerThreadId}`, - `/threads/${ownerThreadId}/events`, - `/threads/${ownerThreadId}/files`, - `/files/${PUBLIC_API_TEST_IDS.file}/content`, - ]) { - const response = await surface.request(path); - expect(response.status).toBe(404); - } - const ownerThreadContinue = await surface.request(`/threads/${ownerThreadId}/events`, { - body: JSON.stringify({ events: [{ text: "hijack", type: "user_message" }] }), - headers: { "Content-Type": "application/json" }, - method: "POST", - }); - expect(ownerThreadContinue.status).toBe(404); - - // Another Deployment of the same App and Agent cannot see this Thread. - const crossDeployment = await otherDeployment.request(`/threads/${threadId}`); - expect(crossDeployment.status).toBe(404); - const crossDeploymentList = await otherDeployment.request("/threads"); - expect(crossDeploymentList.status).toBe(200); - expect(expectArray((await readJson(crossDeploymentList))["threads"])).toEqual([]); - - // The owner still sees the deployment's Thread through the Access Token API. - const ownerView = await surface.ownerRequest(`/threads/${threadId}`); - expect(ownerView.status).toBe(200); - - // A capability minted for an Agent outside the App is refused outright. - const foreignAgent = await createBoundCapabilityClient({ - app: createPublicThreadApiTestApp(), - bindings: surface.bindings, - claims: boundCapabilityClaims({ appId: PUBLIC_API_TEST_IDS.organization }), - }); - const foreignUpload = await foreignAgent.request("/files", { - body: attachmentForm(), - method: "POST", - }); - expect(foreignUpload.status).toBe(409); - expect(expectRecord(await readJson(foreignUpload))["error"]).toMatchObject({ - code: "agent_not_published", - }); - - // A tampered token never authenticates. - const forged = await requestPublicApiWithBindings( - createPublicThreadApiTestApp(), - new Request(`https://api.example.com/api/v1/bound/${surface.token}x/threads`), - surface.bindings, - ); - expect(forged.status).toBe(401); - }); - }); - - test("rejects every bound operation once the deployment is removed or replaced", async () => { - const surface = await createBoundSurface(); - - await withProviderProbeMock(async () => { - const fileId = await uploadAttachment(surface); - const { threadId } = await createThread(surface, fileId); - - await deleteBoundDeployment(surface.database); - - const revokedError = { - code: "agent_not_published", - message: "This capability is no longer authorized for the active deployment.", - }; - - for (const [path, init] of [ - [`/threads/${threadId}`, undefined], - [`/threads/${threadId}/files`, undefined], - ["/threads", undefined], - ["/files", { body: attachmentForm(), method: "POST" }], - [ - "/threads", - { - body: createThreadBody(fileId), - headers: { "Content-Type": "application/json" }, - method: "POST", - }, - ], - [ - `/threads/${threadId}/events`, - { - body: JSON.stringify({ events: [{ text: "again", type: "user_message" }] }), - headers: { "Content-Type": "application/json" }, - method: "POST", - }, - ], - ] as const) { - const response = await surface.request(path, init); - expect(response.status).toBe(409); - expect(expectRecord(await readJson(response))["error"]).toEqual(revokedError); - } - - // The owner keeps full access to the Thread the deployment created. - const ownerView = await surface.ownerRequest(`/threads/${threadId}`); - expect(ownerView.status).toBe(200); - }); - - // A successful replacement revision that drops the binding revokes the old URL. - const replaced = await createBoundSurface(); - await insertBoundDeploymentRun(replaced.database, { - agentBindings: [], - deploymentId: BOUND_DEPLOYMENT_ID, - deploymentRunId: BOUND_REPLACEMENT_DEPLOYMENT_RUN_ID, - }); - const response = await replaced.request("/files", { body: attachmentForm(), method: "POST" }); - expect(response.status).toBe(409); - }); - - test("replays an idempotent create across capability revisions of one deployment", async () => { - const surface = await createBoundSurface(); - - await withProviderProbeMock(async () => { - const fileId = await uploadAttachment(surface); - const body = createThreadBody(fileId); - const headers = { - "Content-Type": "application/json", - "Idempotency-Key": "pet-7", - }; - - const first = await surface.request("/threads", { body, headers, method: "POST" }); - expect(first.status).toBe(201); - const threadId = expectString(expectRecord((await readJson(first))["thread"])["id"]); - - // Redeploy: the new revision keeps the binding, so the old token is - // replaced and the Worker retries with the freshly minted URL. - await insertBoundDeploymentRun(surface.database, { - agentBindings: [BOUND_BINDING], - deploymentId: BOUND_DEPLOYMENT_ID, - deploymentRunId: BOUND_REPLACEMENT_DEPLOYMENT_RUN_ID, - }); - const nextRevision = await createBoundCapabilityClient({ - app: createPublicThreadApiTestApp(), - bindings: surface.bindings, - claims: boundCapabilityClaims({ deploymentRunId: BOUND_REPLACEMENT_DEPLOYMENT_RUN_ID }), - }); - - const staleRetry = await surface.request("/threads", { body, headers, method: "POST" }); - expect(staleRetry.status).toBe(409); - - const retry = await nextRevision.request("/threads", { body, headers, method: "POST" }); - expect(retry.status).toBe(201); - expect(retry.headers.get("Idempotency-Replayed")).toBe("true"); - expect(expectRecord((await readJson(retry))["thread"])["id"]).toBe(threadId); - - // The new revision reads the Thread the previous revision created. - const retrieve = await nextRevision.request(`/threads/${threadId}`); - expect(retrieve.status).toBe(200); - }); - }); - - test("cleans up the Thread when deletion wins the guarded Run insert race", async () => { - const surface = await createBoundSurface(); - const revoking = revokeDeploymentWhenRunInsertStarts(surface.database); - const bindings = createBoundTestBindings(revoking as unknown as SqliteD1Database, { - fileBucket: surface.bucket as unknown as R2Bucket, - }); - const client = await createBoundCapabilityClient({ - app: createPublicThreadApiTestApp(), - bindings, - }); - - await withProviderProbeMock(async () => { - const response = await client.request("/threads", { - body: JSON.stringify({ - input: { content: [{ text: "Hello", type: "text" }], type: "user.message" }, - userId: "end-user-42", - }), - headers: { "Content-Type": "application/json" }, - method: "POST", - }); - - expect(response.status).toBe(409); - expect(expectRecord(await readJson(response))["error"]).toEqual({ - code: "agent_not_published", - message: "This capability is no longer authorized for the active deployment.", - }); - await expect( - surface.database - .prepare("SELECT COUNT(*) AS count FROM session") - .first<{ count: number }>(), - ).resolves.toEqual({ count: 0 }); - await expect( - surface.database - .prepare("SELECT COUNT(*) AS count FROM session_run") - .first<{ count: number }>(), - ).resolves.toEqual({ count: 0 }); - }); - }); -}); - -function revokeDeploymentWhenRunInsertStarts(database: SqliteD1Database): D1Database { - let revoked = false; - - function wrapStatement(statement: D1PreparedStatement, query: string): D1PreparedStatement { - const shouldRevoke = /\bINSERT\s+INTO\s+(?:"session_run"|session_run)(?:\s|\()/iu.test(query); - - return new Proxy(statement, { - get(target, property, receiver) { - if (property === "bind") { - return (...values: unknown[]) => wrapStatement(target.bind(...values), query); - } - - if ( - shouldRevoke && - !revoked && - (property === "all" || property === "first" || property === "raw" || property === "run") - ) { - const method = Reflect.get(target, property, receiver); - - if (typeof method === "function") { - return async (...args: unknown[]) => { - revoked = true; - await deleteBoundDeployment(database); - return method.apply(target, args); - }; - } - } - - return Reflect.get(target, property, receiver); - }, - }); - } - - return { - batch: database.batch.bind(database), - prepare: (query) => wrapStatement(database.prepare(query), query), - } as D1Database; -} diff --git a/apps/api/tests/helpers/api-test-fixture.ts b/apps/api/tests/helpers/api-test-fixture.ts index b07dec7e..2d90e0fe 100644 --- a/apps/api/tests/helpers/api-test-fixture.ts +++ b/apps/api/tests/helpers/api-test-fixture.ts @@ -326,46 +326,6 @@ function createApiTestSchema(database: SqliteD1Database): void { updated_at integer NOT NULL ); - CREATE TABLE app_deployment ( - app_id text NOT NULL, - created_at integer NOT NULL, - default_branch text NOT NULL, - deleted_at integer, - id text PRIMARY KEY NOT NULL, - last_successful_url text, - latest_run_id text, - mosoo_subdomain text NOT NULL, - owner_account_id text NOT NULL, - repo_name text NOT NULL, - repo_owner text NOT NULL, - repo_url text NOT NULL, - source_kind text NOT NULL, - updated_at integer NOT NULL - ); - - CREATE TABLE app_deployment_run ( - app_id text NOT NULL, - created_at integer NOT NULL, - deployment_id text NOT NULL, - error_code text, - error_message text, - external_deployment_id text, - external_project_id text, - external_version_id text, - generated_wrangler_config_json text, - id text PRIMARY KEY NOT NULL, - mosoo_config_json text, - plan_json text, - source_branch text NOT NULL, - source_commit_sha text NOT NULL, - status text NOT NULL, - target_kind text, - target_project_name text, - target_script_name text, - updated_at integer NOT NULL, - url text - ); - CREATE TABLE api_command ( attempt_count integer DEFAULT 0 NOT NULL, claim_expires_at integer, diff --git a/apps/api/tests/helpers/public-api-http-runtime-schema.sql b/apps/api/tests/helpers/public-api-http-runtime-schema.sql index b95294ec..61198dbf 100644 --- a/apps/api/tests/helpers/public-api-http-runtime-schema.sql +++ b/apps/api/tests/helpers/public-api-http-runtime-schema.sql @@ -20,23 +20,6 @@ CREATE TABLE public_api_idempotency_key ( updated_at integer NOT NULL ); -CREATE TABLE bound_agent_call_idempotency_key ( - id text PRIMARY KEY NOT NULL, - subject_hash text NOT NULL, - idempotency_key text NOT NULL, - body_hash text NOT NULL, - session_id text NOT NULL, - run_id text, - created_at integer NOT NULL, - updated_at integer NOT NULL -); - -CREATE UNIQUE INDEX bound_agent_call_idempotency_subject_key_idx - ON bound_agent_call_idempotency_key (subject_hash, idempotency_key); - -CREATE INDEX bound_agent_call_idempotency_updated_idx - ON bound_agent_call_idempotency_key (updated_at); - CREATE TABLE session ( id text PRIMARY KEY NOT NULL, app_id text NOT NULL, @@ -71,12 +54,6 @@ CREATE TABLE session_run ( id text PRIMARY KEY NOT NULL, session_id text NOT NULL, agent_id text NOT NULL, - bound_capability_agent_id text, - bound_capability_app_id text, - bound_capability_binding_env text, - bound_capability_binding_name text, - bound_capability_deployment_id text, - bound_capability_deployment_run_id text, created_by_account_id text NOT NULL, deployment_version_id text, deployment_version_number integer, diff --git a/apps/api/tests/helpers/public-api-http-test-fixture.ts b/apps/api/tests/helpers/public-api-http-test-fixture.ts index 18fc2bd2..ae0ad8ea 100644 --- a/apps/api/tests/helpers/public-api-http-test-fixture.ts +++ b/apps/api/tests/helpers/public-api-http-test-fixture.ts @@ -276,7 +276,6 @@ export function createPublicHttpTestBindings( DB: database, FILE_BUCKET: options.fileBucket ?? unavailableBinding("FILE_BUCKET"), FILE_BUCKET_NAME: "mosoo-file", - MOSOO_APP_DEPLOYMENT_DOMAIN: "apps.localhost", R2_ACCESS_KEY_ID: "test-access-key", R2_SECRET_ACCESS_KEY: "test-secret-key", RUNTIME_ACTION_TOKEN_SECRET: "test-runtime-action-token", diff --git a/apps/api/tests/prod-schema-guard.test.ts b/apps/api/tests/prod-schema-guard.test.ts index 8e37e7b3..36b798d4 100644 --- a/apps/api/tests/prod-schema-guard.test.ts +++ b/apps/api/tests/prod-schema-guard.test.ts @@ -66,7 +66,6 @@ describe("parseExpectedTableNames", () => { const snapshot = await Bun.file(new URL(snapshotFilename, metaDir)).text(); const tableNames = parseExpectedTableNames(snapshot); - expect(tableNames).toContain("bound_agent_call_idempotency_key"); expect(tableNames).toContain("usage_event_rollup_receipt"); }); }); diff --git a/apps/api/tests/public-api-caller-auth.test.ts b/apps/api/tests/public-api-caller-auth.test.ts deleted file mode 100644 index 6c137e96..00000000 --- a/apps/api/tests/public-api-caller-auth.test.ts +++ /dev/null @@ -1,24 +0,0 @@ -import { describe, expect, test } from "bun:test"; - -import { authenticatePublicApiCaller } from "../src/modules/auth/application/public-api-caller.service"; -import { SqliteD1Database } from "./helpers/sqlite-d1"; - -const SERVICE_TOKEN_VALUE = "grt_svc_public_api_caller_auth_token_01"; - -describe("public API caller authentication", () => { - test("rejects service token values as public API callers", async () => { - const database = new SqliteD1Database(); - - const caller = await authenticatePublicApiCaller(database, SERVICE_TOKEN_VALUE); - - expect(caller).toBeNull(); - }); - - test("rejects unknown token prefixes", async () => { - const database = new SqliteD1Database(); - - const caller = await authenticatePublicApiCaller(database, "not_a_known_token"); - - expect(caller).toBeNull(); - }); -}); diff --git a/apps/api/tests/public-thread-api.e2e.test.ts b/apps/api/tests/public-thread-api.e2e.test.ts index 000319f2..fb8b5eb1 100644 --- a/apps/api/tests/public-thread-api.e2e.test.ts +++ b/apps/api/tests/public-thread-api.e2e.test.ts @@ -292,6 +292,7 @@ function failFirstPublicApiIdempotencyCompletion(database: D1Database): D1Databa async function insertPublicThread( database: PublicHttpTestDatabase, input: { + createdBy?: Record; id: string; title: string; updatedAt: number; @@ -314,7 +315,7 @@ async function insertPublicThread( lastRunId: null, metadataJson: JSON.stringify({ public_api: { - created_by: { + created_by: input.createdBy ?? { token_id: PUBLIC_API_TEST_IDS.patOwner, token_label: PUBLIC_API_TEST_IDS.patOwner, }, @@ -820,6 +821,44 @@ describe("Public Thread API e2e", () => { }); }); + test("keeps owner-visible Thread history readable with an opaque retired creator", async () => { + const database = await createPublicHttpContractDatabase(); + const app = createPublicThreadApiTestApp(); + const threadId = generatedPublicThreadId(0); + + await insertPublicThread(database, { + createdBy: { + historical_caller_id: "01J0000000000000000000000H", + historical_caller_kind: "retired", + }, + id: threadId, + title: "Historical Thread", + updatedAt: 1, + }); + + const retrieveResponse = await requestPublicApi( + app, + database, + new Request(`https://api.example.com/api/v1/threads/${threadId}`, { + headers: { Authorization: bearer(TOKENS.owner) }, + }), + ); + expect(retrieveResponse.status).toBe(200); + expect(expectRecord((await readJson(retrieveResponse))["thread"])["id"]).toBe(threadId); + + const listResponse = await requestPublicApi( + app, + database, + new Request(`https://api.example.com/api/v1/agents/${PUBLIC_API_TEST_IDS.agent}/threads`, { + headers: { Authorization: bearer(TOKENS.owner) }, + }), + ); + expect(listResponse.status).toBe(200); + expect(expectArray(expectRecord(await readJson(listResponse))["threads"])).toEqual([ + expect.objectContaining({ id: threadId }), + ]); + }); + test("exposes failed run status without internal error details", async () => { const database = await createPublicHttpContractDatabase(); const app = createPublicThreadApiTestApp(); diff --git a/apps/api/tests/public-thread-metadata.test.ts b/apps/api/tests/public-thread-metadata.test.ts index cbb8e24d..0e3453f7 100644 --- a/apps/api/tests/public-thread-metadata.test.ts +++ b/apps/api/tests/public-thread-metadata.test.ts @@ -1,11 +1,10 @@ import { describe, expect, test } from "bun:test"; -import type { AppDeploymentId, AppDeploymentRunId, PersonalAccessTokenId } from "@mosoo/id"; +import type { PersonalAccessTokenId } from "@mosoo/id"; import { createPublicApiThreadMetadata, - isDeploymentCapabilityCreatedBy, - parsePublicApiThreadMetadata, + parsePublicApiThreadRecordMetadata, } from "../src/modules/public-api/public-thread-metadata"; describe("Public Thread metadata", () => { @@ -18,51 +17,35 @@ describe("Public Thread metadata", () => { idempotencyKey: "idem-1", }); - expect(parsePublicApiThreadMetadata(JSON.stringify({ public_api: metadata }))).toEqual( - metadata, - ); - expect(isDeploymentCapabilityCreatedBy(metadata.created_by)).toBe(false); - }); - - test("round-trips deployment capability audit metadata", () => { - const metadata = createPublicApiThreadMetadata({ - createdBy: { - binding_env: "MOSOO_AGENT_URL", - binding_name: "Codex Pet", - deployment_id: "01J0000000000000000000000D" as AppDeploymentId, - deployment_run_id: "01J0000000000000000000000R" as AppDeploymentRunId, - kind: "deployment_capability", - }, - idempotencyKey: null, + expect(parsePublicApiThreadRecordMetadata(JSON.stringify({ public_api: metadata }))).toEqual({ + idempotency_key: "idem-1", + source: "public_api", }); - - expect(parsePublicApiThreadMetadata(JSON.stringify({ public_api: metadata }))).toEqual( - metadata, - ); - expect(isDeploymentCapabilityCreatedBy(metadata.created_by)).toBe(true); }); - test("rejects deployment capability metadata with unknown or malformed fields", () => { - const base = { - binding_env: "MOSOO_AGENT_URL", - binding_name: "Codex Pet", - deployment_id: "01J0000000000000000000000D", - deployment_run_id: "01J0000000000000000000000R", - kind: "deployment_capability", - }; + test("keeps stored Public Threads readable without interpreting creator history", () => { + expect( + parsePublicApiThreadRecordMetadata( + JSON.stringify({ + public_api: { + created_by: { historical_caller: "retired" }, + idempotency_key: null, + source: "public_api", + }, + }), + ), + ).toEqual({ idempotency_key: null, source: "public_api" }); + }); - for (const createdBy of [ - { ...base, token_id: "01J00000000000000000000061" }, - { ...base, deployment_id: "not-a-ulid" }, - { ...base, binding_env: "" }, - { kind: "deployment_capability" }, + test("rejects malformed Public Thread envelopes", () => { + for (const publicApi of [ + { created_by: null, idempotency_key: null, source: "public_api" }, + { created_by: {}, idempotency_key: 1, source: "public_api" }, + { created_by: {}, idempotency_key: null, source: "other" }, + { created_by: {}, idempotency_key: null, source: "public_api", unknown: true }, ]) { expect( - parsePublicApiThreadMetadata( - JSON.stringify({ - public_api: { created_by: createdBy, idempotency_key: null, source: "public_api" }, - }), - ), + parsePublicApiThreadRecordMetadata(JSON.stringify({ public_api: publicApi })), ).toBeNull(); } }); diff --git a/apps/api/tests/request-logging-path-redaction.test.ts b/apps/api/tests/request-logging-path-redaction.test.ts deleted file mode 100644 index c125c823..00000000 --- a/apps/api/tests/request-logging-path-redaction.test.ts +++ /dev/null @@ -1,27 +0,0 @@ -import { describe, expect, test } from "bun:test"; - -import { redactRequestLogPath } from "../src/adapters/http/request-logging.middleware"; - -describe("request log path redaction", () => { - test("hides the bound capability token but keeps the route shape", () => { - expect(redactRequestLogPath("/api/v1/bound/eyJhbGciOi.signature")).toBe("/api/v1/bound/:token"); - expect(redactRequestLogPath("/api/v1/bound/eyJhbGciOi.signature/threads")).toBe( - "/api/v1/bound/:token/threads", - ); - expect( - redactRequestLogPath( - "/api/v1/bound/eyJhbGciOi.signature/files/01J0000000000000000000000J/content", - ), - ).toBe("/api/v1/bound/:token/files/01J0000000000000000000000J/content"); - }); - - test("leaves every other path untouched", () => { - for (const path of [ - "/api/v1/threads/01J00000000000000000000009", - "/api/v1/bound", - "/graphql", - ]) { - expect(redactRequestLogPath(path)).toBe(path); - } - }); -}); diff --git a/apps/api/tests/session-run-admission-atomicity.test.ts b/apps/api/tests/session-run-admission-atomicity.test.ts index 9be8b6dd..e46827cd 100644 --- a/apps/api/tests/session-run-admission-atomicity.test.ts +++ b/apps/api/tests/session-run-admission-atomicity.test.ts @@ -4,7 +4,7 @@ import { parsePlatformId } from "@mosoo/id"; import type { AgentDeploymentVersionId, SessionId, SessionRunId } from "@mosoo/id"; import { API_COMMAND_QUEUE_SEND_FAILED_CODE } from "../src/modules/api-command/application/api-command-ledger"; -import { getAccountViewer } from "../src/modules/auth/application/public-api-caller.service"; +import { getAccountViewer } from "../src/modules/auth/application/viewer-auth.service"; import type { AuthenticatedViewer } from "../src/modules/auth/application/viewer-auth.service"; import { queueSessionRun } from "../src/modules/runtime/application/session-run.service"; import { setSessionRunStatus } from "../src/modules/runtime/infrastructure/session-runs/session-run-store.repository"; diff --git a/apps/api/wrangler.toml b/apps/api/wrangler.toml index b7ad6d57..8ce99696 100644 --- a/apps/api/wrangler.toml +++ b/apps/api/wrangler.toml @@ -22,7 +22,6 @@ FILE_BUCKET_NAME = "mosoo-file" BACKUP_BUCKET_NAME = "mosoo-sandbox-state" SANDBOX_STATE_BUCKET_NAME = "mosoo-sandbox-state" SANDBOX_FILE_BUCKET_LOCAL = "true" -MOSOO_APP_DEPLOYMENT_DOMAIN = "apps.localhost" MOSOO_ACCOUNT_CONCURRENT_SANDBOX_LIMIT = "5" MOSOO_DEPLOYMENT_MODE = "cloud" MOSOO_ENVIRONMENT = "development" @@ -39,8 +38,6 @@ required = [ "R2_ACCESS_KEY_ID", "R2_SECRET_ACCESS_KEY", "CLOUDFLARE_ACCOUNT_ID", - "CLOUDFLARE_API_TOKEN", - "CLOUDFLARE_ZONE_ID", ] [triggers] @@ -136,7 +133,6 @@ FILE_BUCKET_NAME = "mosoo-stage-file" BACKUP_BUCKET_NAME = "mosoo-stage-sandbox-state" SANDBOX_STATE_BUCKET_NAME = "mosoo-stage-sandbox-state" SANDBOX_FILE_BUCKET_LOCAL = "false" -MOSOO_APP_DEPLOYMENT_DOMAIN = "apps-stage.mosoo.ai" MOSOO_ACCOUNT_CONCURRENT_SANDBOX_LIMIT = "5" MOSOO_DEPLOYMENT_MODE = "cloud" MOSOO_ENVIRONMENT = "production" @@ -153,8 +149,6 @@ required = [ "R2_ACCESS_KEY_ID", "R2_SECRET_ACCESS_KEY", "CLOUDFLARE_ACCOUNT_ID", - "CLOUDFLARE_API_TOKEN", - "CLOUDFLARE_ZONE_ID", "GOOGLE_OAUTH_CLIENT_ID", "GOOGLE_OAUTH_CLIENT_SECRET", ] @@ -253,7 +247,6 @@ FILE_BUCKET_NAME = "mosoo-file" BACKUP_BUCKET_NAME = "mosoo-sandbox-state" SANDBOX_STATE_BUCKET_NAME = "mosoo-sandbox-state" SANDBOX_FILE_BUCKET_LOCAL = "false" -MOSOO_APP_DEPLOYMENT_DOMAIN = "apps.mosoo.ai" MOSOO_ACCOUNT_CONCURRENT_SANDBOX_LIMIT = "5" MOSOO_DEPLOYMENT_MODE = "cloud" MOSOO_ENVIRONMENT = "production" @@ -270,8 +263,6 @@ required = [ "R2_ACCESS_KEY_ID", "R2_SECRET_ACCESS_KEY", "CLOUDFLARE_ACCOUNT_ID", - "CLOUDFLARE_API_TOKEN", - "CLOUDFLARE_ZONE_ID", "GOOGLE_OAUTH_CLIENT_ID", "GOOGLE_OAUTH_CLIENT_SECRET", ] diff --git a/apps/web/src/app/document-title.tsx b/apps/web/src/app/document-title.tsx index 2b583767..e5a4680e 100644 --- a/apps/web/src/app/document-title.tsx +++ b/apps/web/src/app/document-title.tsx @@ -24,7 +24,6 @@ const DEFAULT_TITLE_TRANSLATIONS: Record = { "pageTitle.agents": "Agents", "pageTitle.files": "Files", "pageTitle.cliAuth": "CLI authorization", - "pageTitle.deploymentPreview": "Deployment preview", "pageTitle.onboarding": "Onboarding", "pageTitle.signIn": "Sign in", "pageTitle.orgSettings": "Org settings", @@ -58,7 +57,6 @@ const DOCUMENT_TITLE_RULES: DocumentTitleRule[] = [ { path: "/agent", scope: "app", titleKey: "pageTitle.agents" }, { path: "/files", scope: "app", titleKey: "pageTitle.files" }, { path: "/cli-auth", scope: "global", titleKey: "pageTitle.cliAuth" }, - { path: "/v0-deploy-preview", scope: "global", titleKey: "pageTitle.deploymentPreview" }, { path: "/onboarding", scope: "global", titleKey: "pageTitle.onboarding" }, { path: "/login", scope: "global", titleKey: "pageTitle.signIn" }, { path: "/org/settings", scope: "org", titleKey: "pageTitle.orgSettings" }, diff --git a/apps/web/src/app/route-registry.tsx b/apps/web/src/app/route-registry.tsx index 94061fef..425ad6f2 100644 --- a/apps/web/src/app/route-registry.tsx +++ b/apps/web/src/app/route-registry.tsx @@ -1,8 +1,13 @@ +import { FileQuestion } from "lucide-react"; import { lazy } from "react"; import type { ComponentType, ReactElement, ReactNode } from "react"; -import { Navigate, useParams, useRoutes } from "react-router-dom"; +import { Link, Navigate, useParams, useRoutes } from "react-router-dom"; import type { RouteObject } from "react-router-dom"; +import { useTranslation } from "@/shared/i18n"; +import { Button } from "@/shared/ui/button"; +import { EmptyState } from "@/shared/ui/empty-state"; + import { GuestRoute, OnboardingRoute, ProtectedRoute } from "./route-guards"; type RouteModule = Record; @@ -36,6 +41,22 @@ function NavigateToEnvironmentAlias(): ReactElement { ); } +function NotFoundPage(): ReactElement { + const { t } = useTranslation(); + + return ( + + + + ); +} + const Login = lazyNamed(async () => import("../routes/login/login.route"), "LoginPage"); const Onboarding = lazyNamed( async () => import("../routes/onboarding/onboarding.route"), @@ -95,10 +116,6 @@ const AppOverview = lazyNamed( "AppOverviewPage", ); const AppsList = lazyNamed(async () => import("../routes/apps/apps-list.route"), "AppsListPage"); -const V0DeployPreview = lazyNamed( - async () => import("../routes/app-overview/deploy/v0-deploy-preview.route"), - "V0DeployPreviewPage", -); const OrgSettings = lazyNamed( async () => import("../routes/org/org-settings.route"), "OrgSettingsPage", @@ -143,8 +160,6 @@ const appRoutes = [ { element: protectedRoute(), path: "/mcp" }, { element: protectedRoute(), path: "/integrations/skills" }, { element: protectedRoute(), path: "/integrations/mcp" }, - { element: protectedRoute(), path: "/deployments" }, - { element: , path: "/v0-deploy-preview" }, { element: protectedRoute(), path: "/agent" }, { element: protectedRoute(), path: "/agent/:agentId" }, { element: protectedRoute(), path: "/threads" }, @@ -176,6 +191,7 @@ const appRoutes = [ { element: protectedRoute(), path: "/usage" }, { element: protectedRoute(), path: "/providers" }, { element: protectedRoute(), path: "/cost" }, + { element: protectedRoute(), path: "*" }, ] satisfies RouteObject[]; export function AppRoutes(): ReactNode { diff --git a/apps/web/src/domains/app/api/app-deployment-client.ts b/apps/web/src/domains/app/api/app-deployment-client.ts deleted file mode 100644 index bf179cb8..00000000 --- a/apps/web/src/domains/app/api/app-deployment-client.ts +++ /dev/null @@ -1,241 +0,0 @@ -import type { - AppDeployment, - AppDeploymentRun, - AppDeploymentRunStatus, - AppDeploymentTargetKind, - AppOverviewBoundAgent, - AppOverviewBoundAgentExposure, - DeleteAppDeploymentInput, - DeployAppInput, -} from "@mosoo/contracts/app"; -import type { AppId } from "@mosoo/contracts/id"; -import type { PlatformId } from "@mosoo/id"; - -import { graphql } from "@/gql"; -import { requestGraphQL } from "@/platform/http/graphql-client"; -import { toAgentId, toAppDeploymentId, toAppDeploymentRunId, toAppId } from "@/routes/typed-id"; - -/** - * Strongly typed GraphQL access for the Deploy console (App overview + - * deployment lifecycle). Mirrors {@link file://./app-client.ts}: `graphql()` - * tagged documents drive `requestGraphQL`, and the raw payloads are mapped back - * onto the shared `@mosoo/contracts/app` domain types. - */ - -const APP_DEPLOYMENT_OVERVIEW_QUERY = graphql(/* GraphQL */ ` - query AppDeploymentOverview($appId: ULID!) { - appOverview(appId: $appId) { - app { - id - name - } - boundAgents { - agentId - envVar - expose - name - } - deployment { - appId - createdAt - defaultBranch - id - liveUrl - plannedUrl - repoName - repoOwner - repoUrl - updatedAt - latestRun { - appId - createdAt - deploymentId - errorCode - errorMessage - id - liveUrl - plannedUrl - sourceBranch - sourceCommitSha - status - targetKind - updatedAt - } - } - } - } -`); - -const APP_DEPLOYMENT_RUN_LIST_QUERY = graphql(/* GraphQL */ ` - query AppDeploymentRunList($appId: ULID!, $limit: Int) { - appDeploymentRunList(appId: $appId, limit: $limit) { - appId - createdAt - deploymentId - errorCode - errorMessage - id - liveUrl - plannedUrl - sourceBranch - sourceCommitSha - status - targetKind - updatedAt - } - } -`); - -const DEPLOY_APP_MUTATION = graphql(/* GraphQL */ ` - mutation DeployApp($input: DeployAppInput!) { - deployApp(input: $input) { - appId - createdAt - deploymentId - errorCode - errorMessage - id - liveUrl - plannedUrl - sourceBranch - sourceCommitSha - status - targetKind - updatedAt - } - } -`); - -const DELETE_APP_DEPLOYMENT_MUTATION = graphql(/* GraphQL */ ` - mutation DeleteAppDeployment($input: DeleteAppDeploymentInput!) { - deleteAppDeployment(input: $input) { - ok - } - } -`); - -/** - * Focused view of `appOverview` consumed by the Deploy console — the App's - * display name plus its deployment and self-authorizing agent bindings. - */ -export interface AppDeploymentOverview { - appName: string; - boundAgents: AppOverviewBoundAgent[]; - deployment: AppDeployment | null; -} - -interface RawDeploymentRun { - appId: PlatformId; - createdAt: string; - deploymentId: PlatformId; - errorCode: string | null; - errorMessage: string | null; - id: PlatformId; - liveUrl: string | null; - plannedUrl: string; - sourceBranch: string; - sourceCommitSha: string; - status: AppDeploymentRunStatus; - targetKind: AppDeploymentTargetKind | null; - updatedAt: string; -} - -interface RawDeployment { - appId: PlatformId; - createdAt: string; - defaultBranch: string; - id: PlatformId; - latestRun: RawDeploymentRun | null; - liveUrl: string | null; - plannedUrl: string; - repoName: string; - repoOwner: string; - repoUrl: string; - updatedAt: string; -} - -interface RawBoundAgent { - agentId: PlatformId; - envVar: string; - expose: AppOverviewBoundAgentExposure; - name: string; -} - -function toAppDeploymentRun(run: RawDeploymentRun): AppDeploymentRun { - return { - appId: toAppId(run.appId), - createdAt: run.createdAt, - deploymentId: toAppDeploymentId(run.deploymentId), - errorCode: run.errorCode, - errorMessage: run.errorMessage, - id: toAppDeploymentRunId(run.id), - liveUrl: run.liveUrl, - plannedUrl: run.plannedUrl, - sourceBranch: run.sourceBranch, - sourceCommitSha: run.sourceCommitSha, - status: run.status, - targetKind: run.targetKind, - updatedAt: run.updatedAt, - }; -} - -function toAppDeployment(deployment: RawDeployment): AppDeployment { - return { - appId: toAppId(deployment.appId), - createdAt: deployment.createdAt, - defaultBranch: deployment.defaultBranch, - id: toAppDeploymentId(deployment.id), - latestRun: deployment.latestRun === null ? null : toAppDeploymentRun(deployment.latestRun), - liveUrl: deployment.liveUrl, - plannedUrl: deployment.plannedUrl, - repoName: deployment.repoName, - repoOwner: deployment.repoOwner, - repoUrl: deployment.repoUrl, - updatedAt: deployment.updatedAt, - }; -} - -function toBoundAgent(agent: RawBoundAgent): AppOverviewBoundAgent { - return { - agentId: toAgentId(agent.agentId), - envVar: agent.envVar, - expose: agent.expose, - name: agent.name, - }; -} - -export async function getAppDeploymentOverview(appId: AppId): Promise { - const payload = await requestGraphQL(APP_DEPLOYMENT_OVERVIEW_QUERY, { appId }); - const { app, boundAgents, deployment } = payload.appOverview; - - return { - appName: app.name, - boundAgents: boundAgents.map(toBoundAgent), - deployment: deployment === null ? null : toAppDeployment(deployment), - }; -} - -/** Deployment runs for the App, newest first (server default 20, cap 50). */ -export async function listAppDeploymentRuns( - appId: AppId, - limit?: number, -): Promise { - const payload = await requestGraphQL(APP_DEPLOYMENT_RUN_LIST_QUERY, { - appId, - limit: limit ?? null, - }); - - return payload.appDeploymentRunList.map(toAppDeploymentRun); -} - -export async function deployApp(input: DeployAppInput): Promise { - const payload = await requestGraphQL(DEPLOY_APP_MUTATION, { input }); - - return toAppDeploymentRun(payload.deployApp); -} - -export async function deleteAppDeployment(input: DeleteAppDeploymentInput): Promise { - const payload = await requestGraphQL(DELETE_APP_DEPLOYMENT_MUTATION, { input }); - - return payload.deleteAppDeployment.ok; -} diff --git a/apps/web/src/domains/app/query/app-deployment-queries.ts b/apps/web/src/domains/app/query/app-deployment-queries.ts deleted file mode 100644 index 1ed79aa1..00000000 --- a/apps/web/src/domains/app/query/app-deployment-queries.ts +++ /dev/null @@ -1,122 +0,0 @@ -import type { - AppDeploymentRun, - AppDeploymentRunStatus, - DeleteAppDeploymentInput, - DeployAppInput, -} from "@mosoo/contracts/app"; -import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; -import type { UseMutationResult, UseQueryResult } from "@tanstack/react-query"; - -import { toAppId } from "@/routes/typed-id"; - -import { - deleteAppDeployment, - deployApp, - getAppDeploymentOverview, - listAppDeploymentRuns, -} from "../api/app-deployment-client"; -import type { AppDeploymentOverview } from "../api/app-deployment-client"; - -const appDeploymentKeys = { - all: ["app-deployment"] as const, - missingOverview: () => [...appDeploymentKeys.overviews(), "missing"] as const, - missingRunList: () => [...appDeploymentKeys.runLists(), "missing"] as const, - overview: (appId: string) => [...appDeploymentKeys.overviews(), appId] as const, - overviews: () => [...appDeploymentKeys.all, "overview"] as const, - runList: (appId: string) => [...appDeploymentKeys.runLists(), appId] as const, - runLists: () => [...appDeploymentKeys.all, "run-list"] as const, -}; - -/** Match the server-side cap so run numbering covers the widest window. */ -const RUN_LIST_LIMIT = 50; - -export const IN_FLIGHT_STATUSES: ReadonlySet = new Set([ - "activating", - "building", - "preparing", - "queued", - "submitted", - "submitting", -]); - -/** A run is still settling, so the console should keep polling for progress. */ -export function isDeploymentRunInFlight(status: AppDeploymentRunStatus | undefined): boolean { - return status !== undefined && IN_FLIGHT_STATUSES.has(status); -} - -function requireAppId(appId: string | null): string { - if (appId === null || appId.length === 0) { - throw new Error("App id is required to load deployment data."); - } - - return appId; -} - -export function useAppDeploymentOverviewQuery( - appId: string | null, -): UseQueryResult { - return useQuery({ - enabled: appId !== null, - queryFn: async () => getAppDeploymentOverview(toAppId(requireAppId(appId))), - queryKey: - appId !== null ? appDeploymentKeys.overview(appId) : appDeploymentKeys.missingOverview(), - refetchInterval: (query) => - isDeploymentRunInFlight(query.state.data?.deployment?.latestRun?.status) ? 2_500 : false, - }); -} - -/** - * Run history for the Activity table. Disabled until the overview shows a - * deployment (a pre-deploy app has no runs to fetch), and never polls on its - * own — the overview query is the single 2.5s poller while a run is in flight, - * and the console refetches this list when the overview's latest run moves. - */ -export function useAppDeploymentRunsQuery( - appId: string | null, - hasDeployment: boolean, -): UseQueryResult { - return useQuery({ - enabled: appId !== null && hasDeployment, - queryFn: async () => listAppDeploymentRuns(toAppId(requireAppId(appId)), RUN_LIST_LIMIT), - queryKey: - appId !== null ? appDeploymentKeys.runList(appId) : appDeploymentKeys.missingRunList(), - }); -} - -export function useDeployAppMutation( - appId: string | null, -): UseMutationResult { - const queryClient = useQueryClient(); - - return useMutation({ - mutationFn: deployApp, - onSuccess: async () => { - if (appId === null) { - return; - } - await Promise.all([ - queryClient.invalidateQueries({ queryKey: appDeploymentKeys.overview(appId) }), - queryClient.invalidateQueries({ queryKey: appDeploymentKeys.runList(appId) }), - ]); - }, - }); -} - -export function useDeleteAppDeploymentMutation( - appId: string | null, -): UseMutationResult { - const queryClient = useQueryClient(); - - return useMutation({ - mutationFn: deleteAppDeployment, - onSuccess: async () => { - if (appId === null) { - return; - } - await Promise.all([ - queryClient.invalidateQueries({ queryKey: appDeploymentKeys.overview(appId) }), - queryClient.invalidateQueries({ queryKey: appDeploymentKeys.runList(appId) }), - ]); - }, - }); -} diff --git a/apps/web/src/gql/gql.ts b/apps/web/src/gql/gql.ts index 807c6f62..b47925eb 100644 --- a/apps/web/src/gql/gql.ts +++ b/apps/web/src/gql/gql.ts @@ -37,10 +37,6 @@ type Documents = { "\n query AppList($organizationId: ULID!) {\n appList(organizationId: $organizationId) {\n createdAt\n defaultEnvironmentId\n id\n name\n ownerAccountId\n }\n }\n": typeof types.AppListDocument, "\n mutation CreateApp($input: CreateAppInput!) {\n createApp(input: $input) {\n createdAt\n defaultEnvironmentId\n id\n name\n ownerAccountId\n }\n }\n": typeof types.CreateAppDocument, "\n mutation RenameApp($input: RenameAppInput!) {\n renameApp(input: $input) {\n createdAt\n defaultEnvironmentId\n id\n name\n ownerAccountId\n }\n }\n": typeof types.RenameAppDocument, - "\n query AppDeploymentOverview($appId: ULID!) {\n appOverview(appId: $appId) {\n app {\n id\n name\n }\n boundAgents {\n agentId\n envVar\n expose\n name\n }\n deployment {\n appId\n createdAt\n defaultBranch\n id\n liveUrl\n plannedUrl\n repoName\n repoOwner\n repoUrl\n updatedAt\n latestRun {\n appId\n createdAt\n deploymentId\n errorCode\n errorMessage\n id\n liveUrl\n plannedUrl\n sourceBranch\n sourceCommitSha\n status\n targetKind\n updatedAt\n }\n }\n }\n }\n": typeof types.AppDeploymentOverviewDocument, - "\n query AppDeploymentRunList($appId: ULID!, $limit: Int) {\n appDeploymentRunList(appId: $appId, limit: $limit) {\n appId\n createdAt\n deploymentId\n errorCode\n errorMessage\n id\n liveUrl\n plannedUrl\n sourceBranch\n sourceCommitSha\n status\n targetKind\n updatedAt\n }\n }\n": typeof types.AppDeploymentRunListDocument, - "\n mutation DeployApp($input: DeployAppInput!) {\n deployApp(input: $input) {\n appId\n createdAt\n deploymentId\n errorCode\n errorMessage\n id\n liveUrl\n plannedUrl\n sourceBranch\n sourceCommitSha\n status\n targetKind\n updatedAt\n }\n }\n": typeof types.DeployAppDocument, - "\n mutation DeleteAppDeployment($input: DeleteAppDeploymentInput!) {\n deleteAppDeployment(input: $input) {\n ok\n }\n }\n": typeof types.DeleteAppDeploymentDocument, "\n fragment CostTotalsFields on CostAggregate {\n activeUsers\n cacheCreationTokens\n cacheReadTokens\n inputTokens\n outputTokens\n requestCount\n totalCostUsd\n unpricedRequestCount\n }\n": typeof types.CostTotalsFieldsFragmentDoc, "\n fragment CostDailyFields on CostDailyPoint {\n activeUsers\n cacheCreationTokens\n cacheReadTokens\n date\n inputTokens\n outputTokens\n requestCount\n totalCostUsd\n unpricedRequestCount\n }\n": typeof types.CostDailyFieldsFragmentDoc, "\n fragment CostAgentFields on CostAgentRow {\n activeUsers\n agentId\n agentName\n cacheCreationTokens\n cacheReadTokens\n debugCostUsd\n evalCostUsd\n inputTokens\n outputTokens\n ownerEmail\n ownerId\n ownerName\n previousCostUsd\n previewCostUsd\n productionCostUsd\n requestCount\n scheduledCostUsd\n totalCostUsd\n unpricedRequestCount\n }\n": typeof types.CostAgentFieldsFragmentDoc, @@ -128,10 +124,6 @@ const documents: Documents = { "\n query AppList($organizationId: ULID!) {\n appList(organizationId: $organizationId) {\n createdAt\n defaultEnvironmentId\n id\n name\n ownerAccountId\n }\n }\n": types.AppListDocument, "\n mutation CreateApp($input: CreateAppInput!) {\n createApp(input: $input) {\n createdAt\n defaultEnvironmentId\n id\n name\n ownerAccountId\n }\n }\n": types.CreateAppDocument, "\n mutation RenameApp($input: RenameAppInput!) {\n renameApp(input: $input) {\n createdAt\n defaultEnvironmentId\n id\n name\n ownerAccountId\n }\n }\n": types.RenameAppDocument, - "\n query AppDeploymentOverview($appId: ULID!) {\n appOverview(appId: $appId) {\n app {\n id\n name\n }\n boundAgents {\n agentId\n envVar\n expose\n name\n }\n deployment {\n appId\n createdAt\n defaultBranch\n id\n liveUrl\n plannedUrl\n repoName\n repoOwner\n repoUrl\n updatedAt\n latestRun {\n appId\n createdAt\n deploymentId\n errorCode\n errorMessage\n id\n liveUrl\n plannedUrl\n sourceBranch\n sourceCommitSha\n status\n targetKind\n updatedAt\n }\n }\n }\n }\n": types.AppDeploymentOverviewDocument, - "\n query AppDeploymentRunList($appId: ULID!, $limit: Int) {\n appDeploymentRunList(appId: $appId, limit: $limit) {\n appId\n createdAt\n deploymentId\n errorCode\n errorMessage\n id\n liveUrl\n plannedUrl\n sourceBranch\n sourceCommitSha\n status\n targetKind\n updatedAt\n }\n }\n": types.AppDeploymentRunListDocument, - "\n mutation DeployApp($input: DeployAppInput!) {\n deployApp(input: $input) {\n appId\n createdAt\n deploymentId\n errorCode\n errorMessage\n id\n liveUrl\n plannedUrl\n sourceBranch\n sourceCommitSha\n status\n targetKind\n updatedAt\n }\n }\n": types.DeployAppDocument, - "\n mutation DeleteAppDeployment($input: DeleteAppDeploymentInput!) {\n deleteAppDeployment(input: $input) {\n ok\n }\n }\n": types.DeleteAppDeploymentDocument, "\n fragment CostTotalsFields on CostAggregate {\n activeUsers\n cacheCreationTokens\n cacheReadTokens\n inputTokens\n outputTokens\n requestCount\n totalCostUsd\n unpricedRequestCount\n }\n": types.CostTotalsFieldsFragmentDoc, "\n fragment CostDailyFields on CostDailyPoint {\n activeUsers\n cacheCreationTokens\n cacheReadTokens\n date\n inputTokens\n outputTokens\n requestCount\n totalCostUsd\n unpricedRequestCount\n }\n": types.CostDailyFieldsFragmentDoc, "\n fragment CostAgentFields on CostAgentRow {\n activeUsers\n agentId\n agentName\n cacheCreationTokens\n cacheReadTokens\n debugCostUsd\n evalCostUsd\n inputTokens\n outputTokens\n ownerEmail\n ownerId\n ownerName\n previousCostUsd\n previewCostUsd\n productionCostUsd\n requestCount\n scheduledCostUsd\n totalCostUsd\n unpricedRequestCount\n }\n": types.CostAgentFieldsFragmentDoc, @@ -285,22 +277,6 @@ export function graphql(source: "\n mutation CreateApp($input: CreateAppInput!) * The graphql function is used to parse GraphQL queries into a document that can be used by GraphQL clients. */ export function graphql(source: "\n mutation RenameApp($input: RenameAppInput!) {\n renameApp(input: $input) {\n createdAt\n defaultEnvironmentId\n id\n name\n ownerAccountId\n }\n }\n"): typeof import('./graphql').RenameAppDocument; -/** - * The graphql function is used to parse GraphQL queries into a document that can be used by GraphQL clients. - */ -export function graphql(source: "\n query AppDeploymentOverview($appId: ULID!) {\n appOverview(appId: $appId) {\n app {\n id\n name\n }\n boundAgents {\n agentId\n envVar\n expose\n name\n }\n deployment {\n appId\n createdAt\n defaultBranch\n id\n liveUrl\n plannedUrl\n repoName\n repoOwner\n repoUrl\n updatedAt\n latestRun {\n appId\n createdAt\n deploymentId\n errorCode\n errorMessage\n id\n liveUrl\n plannedUrl\n sourceBranch\n sourceCommitSha\n status\n targetKind\n updatedAt\n }\n }\n }\n }\n"): typeof import('./graphql').AppDeploymentOverviewDocument; -/** - * The graphql function is used to parse GraphQL queries into a document that can be used by GraphQL clients. - */ -export function graphql(source: "\n query AppDeploymentRunList($appId: ULID!, $limit: Int) {\n appDeploymentRunList(appId: $appId, limit: $limit) {\n appId\n createdAt\n deploymentId\n errorCode\n errorMessage\n id\n liveUrl\n plannedUrl\n sourceBranch\n sourceCommitSha\n status\n targetKind\n updatedAt\n }\n }\n"): typeof import('./graphql').AppDeploymentRunListDocument; -/** - * The graphql function is used to parse GraphQL queries into a document that can be used by GraphQL clients. - */ -export function graphql(source: "\n mutation DeployApp($input: DeployAppInput!) {\n deployApp(input: $input) {\n appId\n createdAt\n deploymentId\n errorCode\n errorMessage\n id\n liveUrl\n plannedUrl\n sourceBranch\n sourceCommitSha\n status\n targetKind\n updatedAt\n }\n }\n"): typeof import('./graphql').DeployAppDocument; -/** - * The graphql function is used to parse GraphQL queries into a document that can be used by GraphQL clients. - */ -export function graphql(source: "\n mutation DeleteAppDeployment($input: DeleteAppDeploymentInput!) {\n deleteAppDeployment(input: $input) {\n ok\n }\n }\n"): typeof import('./graphql').DeleteAppDeploymentDocument; /** * The graphql function is used to parse GraphQL queries into a document that can be used by GraphQL clients. */ diff --git a/apps/web/src/gql/graphql.ts b/apps/web/src/gql/graphql.ts index 5a64e859..b8cfa0ea 100644 --- a/apps/web/src/gql/graphql.ts +++ b/apps/web/src/gql/graphql.ts @@ -134,23 +134,6 @@ export type AgentViewerRole = export type AgentVisibility = | 'private'; -export type AppDeploymentRunStatus = - | 'activating' - | 'building' - | 'failed' - | 'preparing' - | 'queued' - | 'submitted' - | 'submitting' - | 'success'; - -export type AppDeploymentTargetKind = - | 'cloudflare_pages' - | 'cloudflare_worker'; - -export type AppOverviewBoundAgentExposure = - | 'public_thread'; - export type AuthMethod = | 'email_otp' | 'google_oauth'; @@ -257,10 +240,6 @@ export type DeleteAgentInput = { appId: PlatformId; }; -export type DeleteAppDeploymentInput = { - appId: PlatformId; -}; - export type DeleteEnvironmentInput = { appId: PlatformId; environmentId: PlatformId; @@ -271,12 +250,6 @@ export type DeleteVendorCredentialInput = { id: PlatformId; }; -export type DeployAppInput = { - appId: PlatformId; - configPath?: string | null | undefined; - repoUrl: string; -}; - export type EnvironmentNetworkPolicy = | 'full' | 'limited'; @@ -709,35 +682,6 @@ export type RenameAppMutationVariables = Exact<{ export type RenameAppMutation = { renameApp: { createdAt: string, defaultEnvironmentId: PlatformId | null, id: PlatformId, name: string, ownerAccountId: PlatformId } }; -export type AppDeploymentOverviewQueryVariables = Exact<{ - appId: PlatformId; -}>; - - -export type AppDeploymentOverviewQuery = { appOverview: { app: { id: PlatformId, name: string }, boundAgents: Array<{ agentId: PlatformId, envVar: string, expose: AppOverviewBoundAgentExposure, name: string }>, deployment: { appId: PlatformId, createdAt: string, defaultBranch: string, id: PlatformId, liveUrl: string | null, plannedUrl: string, repoName: string, repoOwner: string, repoUrl: string, updatedAt: string, latestRun: { appId: PlatformId, createdAt: string, deploymentId: PlatformId, errorCode: string | null, errorMessage: string | null, id: PlatformId, liveUrl: string | null, plannedUrl: string, sourceBranch: string, sourceCommitSha: string, status: AppDeploymentRunStatus, targetKind: AppDeploymentTargetKind | null, updatedAt: string } | null } | null } }; - -export type AppDeploymentRunListQueryVariables = Exact<{ - appId: PlatformId; - limit?: number | null | undefined; -}>; - - -export type AppDeploymentRunListQuery = { appDeploymentRunList: Array<{ appId: PlatformId, createdAt: string, deploymentId: PlatformId, errorCode: string | null, errorMessage: string | null, id: PlatformId, liveUrl: string | null, plannedUrl: string, sourceBranch: string, sourceCommitSha: string, status: AppDeploymentRunStatus, targetKind: AppDeploymentTargetKind | null, updatedAt: string }> }; - -export type DeployAppMutationVariables = Exact<{ - input: DeployAppInput; -}>; - - -export type DeployAppMutation = { deployApp: { appId: PlatformId, createdAt: string, deploymentId: PlatformId, errorCode: string | null, errorMessage: string | null, id: PlatformId, liveUrl: string | null, plannedUrl: string, sourceBranch: string, sourceCommitSha: string, status: AppDeploymentRunStatus, targetKind: AppDeploymentTargetKind | null, updatedAt: string } }; - -export type DeleteAppDeploymentMutationVariables = Exact<{ - input: DeleteAppDeploymentInput; -}>; - - -export type DeleteAppDeploymentMutation = { deleteAppDeployment: { ok: boolean } }; - type CostTotalsFields_CostAgentRow_Fragment = { activeUsers: number, cacheCreationTokens: number, cacheReadTokens: number, inputTokens: number, outputTokens: number, requestCount: number, totalCostUsd: number, unpricedRequestCount: number }; type CostTotalsFields_CostDailyPoint_Fragment = { activeUsers: number, cacheCreationTokens: number, cacheReadTokens: number, inputTokens: number, outputTokens: number, requestCount: number, totalCostUsd: number, unpricedRequestCount: number }; @@ -2203,94 +2147,6 @@ export const RenameAppDocument = /*#__PURE__*/ new TypedDocumentString(` } } `) as unknown as TypedDocumentString; -export const AppDeploymentOverviewDocument = /*#__PURE__*/ new TypedDocumentString(` - query AppDeploymentOverview($appId: ULID!) { - appOverview(appId: $appId) { - app { - id - name - } - boundAgents { - agentId - envVar - expose - name - } - deployment { - appId - createdAt - defaultBranch - id - liveUrl - plannedUrl - repoName - repoOwner - repoUrl - updatedAt - latestRun { - appId - createdAt - deploymentId - errorCode - errorMessage - id - liveUrl - plannedUrl - sourceBranch - sourceCommitSha - status - targetKind - updatedAt - } - } - } -} - `) as unknown as TypedDocumentString; -export const AppDeploymentRunListDocument = /*#__PURE__*/ new TypedDocumentString(` - query AppDeploymentRunList($appId: ULID!, $limit: Int) { - appDeploymentRunList(appId: $appId, limit: $limit) { - appId - createdAt - deploymentId - errorCode - errorMessage - id - liveUrl - plannedUrl - sourceBranch - sourceCommitSha - status - targetKind - updatedAt - } -} - `) as unknown as TypedDocumentString; -export const DeployAppDocument = /*#__PURE__*/ new TypedDocumentString(` - mutation DeployApp($input: DeployAppInput!) { - deployApp(input: $input) { - appId - createdAt - deploymentId - errorCode - errorMessage - id - liveUrl - plannedUrl - sourceBranch - sourceCommitSha - status - targetKind - updatedAt - } -} - `) as unknown as TypedDocumentString; -export const DeleteAppDeploymentDocument = /*#__PURE__*/ new TypedDocumentString(` - mutation DeleteAppDeployment($input: DeleteAppDeploymentInput!) { - deleteAppDeployment(input: $input) { - ok - } -} - `) as unknown as TypedDocumentString; export const AppCostCardDocument = /*#__PURE__*/ new TypedDocumentString(` query AppCostCard($appId: ULID!, $range: CostRange!, $runPurposes: [CostRunPurpose!]) { appCostCard(appId: $appId, range: $range, runPurposes: $runPurposes) { diff --git a/apps/web/src/import-meta.d.ts b/apps/web/src/import-meta.d.ts index c34833e3..93c9f1ef 100644 --- a/apps/web/src/import-meta.d.ts +++ b/apps/web/src/import-meta.d.ts @@ -1,5 +1,4 @@ interface ImportMetaEnv { - readonly VITE_APP_DEPLOYMENT_LOCAL_PREVIEW_URL?: string; readonly VITE_MOSOO_DEPLOYMENT_MODE?: string; readonly VITE_MOSOO_ENVIRONMENT?: string; readonly VITE_POSTHOG_API_HOST?: string; diff --git a/apps/web/src/routes/app-overview/app-overview-install.tsx b/apps/web/src/routes/app-overview/app-overview-install.tsx index c6cd771b..a0013909 100644 --- a/apps/web/src/routes/app-overview/app-overview-install.tsx +++ b/apps/web/src/routes/app-overview/app-overview-install.tsx @@ -136,7 +136,7 @@ function ConsoleLane(): ReactElement { } /** - * The pre-deploy Overview hero, split into two explicit setup lanes: "In your + * The Overview onboarding hero, split into two explicit setup lanes: "In your * coding agent" (install command, auto-minted CLI token, copyable setup * prompt) and "In the console" (the three-step checklist). Both lanes read * the same account state, so progress counts once wherever a step happens. diff --git a/apps/web/src/routes/app-overview/app-overview.route.tsx b/apps/web/src/routes/app-overview/app-overview.route.tsx index cfa020ac..be4cb32b 100644 --- a/apps/web/src/routes/app-overview/app-overview.route.tsx +++ b/apps/web/src/routes/app-overview/app-overview.route.tsx @@ -1,23 +1,15 @@ -import { Bot, KeyRound } from "lucide-react"; +import { Bot, Box, KeyRound } from "lucide-react"; import { Link } from "react-router-dom"; import { useAppSession } from "@/app/session-provider"; import { useTranslation } from "@/shared/i18n"; +import { AppIdBadge } from "@/shared/ui/app-id-badge"; import { AppOverviewInstallGuide } from "./app-overview-install"; -import { DeploySurface } from "./deploy/deploy-surface"; -import { useLiveDeployConsole } from "./deploy/use-live-deploy-console"; -/** - * The App Overview at "/": before the first deploy it is the install guide - * plus the deploy-from-repo card; once a deployment exists it becomes the - * deploy console — status, live URL, source, bound agents, and run activity. - */ export function AppOverviewPage() { const { activeApp, appsLoading } = useAppSession(); const { t } = useTranslation(); - const appId = activeApp?.id ?? null; - const live = useLiveDeployConsole(appId, activeApp?.name ?? ""); if (activeApp === null) { return ( @@ -28,18 +20,21 @@ export function AppOverviewPage() { } return ( - } - headerActions={ - <> +
+
+
+
+ + {t("nav.app")} +
+
+

+ {activeApp.name} +

+ +
+
+
{t("appOverview.newAgent")} - - } - /> +
+
+ +
+
+ +
+
+
); } diff --git a/apps/web/src/routes/app-overview/deploy/components/deploy-actions.tsx b/apps/web/src/routes/app-overview/deploy/components/deploy-actions.tsx deleted file mode 100644 index e9e7a543..00000000 --- a/apps/web/src/routes/app-overview/deploy/components/deploy-actions.tsx +++ /dev/null @@ -1,122 +0,0 @@ -import { MoreHorizontal, RotateCw, Trash2 } from "lucide-react"; -import { useState } from "react"; - -import { useTranslation } from "@/shared/i18n"; -import { cn } from "@/shared/lib/class-names"; -import { Button } from "@/shared/ui/button"; -import { - Dialog, - DialogContent, - DialogDescription, - DialogFooter, - DialogHeader, - DialogTitle, -} from "@/shared/ui/dialog"; -import { - DropdownMenu, - DropdownMenuContent, - DropdownMenuItem, - DropdownMenuTrigger, -} from "@/shared/ui/dropdown-menu"; - -import type { DeploymentRunOutcome } from "../deployment-status"; -import type { LocalDeploymentPreviewStatus } from "../local-preview-url"; - -type DeployActionScope = "development" | "production"; - -/** - * Header actions for a deployed App: the primary Redeploy/Retry button, plus an - * overflow menu holding "Delete deployment" behind a confirm dialog. Rendered - * only when a deployment exists. - */ -export function DeployActions({ - appName, - agentCount, - latestOutcome, - deploying, - canDeploy, - onRetry, - onDelete, - scope = "production", - developmentStatus = null, -}: { - appName: string; - agentCount: number; - latestOutcome: DeploymentRunOutcome | null; - deploying: boolean; - canDeploy: boolean; - onRetry: () => void; - onDelete: () => void; - scope?: DeployActionScope; - developmentStatus?: LocalDeploymentPreviewStatus | null; -}) { - const { t } = useTranslation(); - const [confirmingDelete, setConfirmingDelete] = useState(false); - - const label = - scope === "development" - ? deploying - ? t("deploy.checkingDevelopment") - : developmentStatus === "online" - ? t("deploy.refreshDevelopment") - : t("deploy.retryDevelopment") - : deploying - ? t("deploy.refreshingProduction") - : latestOutcome === "failed" - ? t("deploy.retryProduction") - : t("deploy.refreshProduction"); - - function confirmDelete() { - onDelete(); - setConfirmingDelete(false); - } - - return ( - <> - - - - - - - setConfirmingDelete(true)} - > - - {t("deploy.deleteDeployment")} - - - - - - - - {t("deploy.deletePrompt")} - - {t("deploy.deleteDescription", { - agentCount: String(agentCount), - appName, - })} - - - - - - - - - - ); -} diff --git a/apps/web/src/routes/app-overview/deploy/components/deploy-overview.tsx b/apps/web/src/routes/app-overview/deploy/components/deploy-overview.tsx deleted file mode 100644 index 9551d46b..00000000 --- a/apps/web/src/routes/app-overview/deploy/components/deploy-overview.tsx +++ /dev/null @@ -1,199 +0,0 @@ -import { Code2, ExternalLink, GitBranch } from "lucide-react"; -import { useState } from "react"; - -import { useTranslation } from "@/shared/i18n"; -import { Badge } from "@/shared/ui/badge"; -import { Button } from "@/shared/ui/button"; -import { Separator } from "@/shared/ui/separator"; - -import { DEPLOY_TARGET_LABELS } from "../deploy-console-data"; -import type { DeploymentRunVM, DeploymentVM } from "../deploy-console-data"; -import { hostOf } from "../deploy-console-mapping"; -import type { LocalDeploymentPreviewState } from "../local-preview-url"; -import { RepoDeployForm } from "./deploy-repo-card"; -import { DeployUrlCard } from "./deploy-url-card"; - -function previewUnavailableLabel( - deployment: DeploymentVM, - localPreview: LocalDeploymentPreviewState, - t: (key: string) => string, -): string { - if (localPreview.status === "checking") { - return t("deploy.checkingDevelopmentPreview"); - } - if (localPreview.status === "offline") { - return t("deploy.developmentPreviewOffline"); - } - if (deployment.liveUrl !== null) { - return hostOf(deployment.liveUrl); - } - return t("deploy.previewUnavailable"); -} - -function PreviewFrame({ - deployment, - localPreview, -}: { - deployment: DeploymentVM; - localPreview: LocalDeploymentPreviewState; -}) { - const { t } = useTranslation(); - const localPreviewReady = localPreview.url !== null && localPreview.status === "online"; - const previewLinkUrl = localPreviewReady ? localPreview.url : deployment.liveUrl; - const previewLabel = - localPreviewReady && localPreview.url !== null - ? hostOf(localPreview.url) - : previewUnavailableLabel(deployment, localPreview, t); - - return ( -
-
- {previewLinkUrl !== null ? ( - <> -