Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 5 additions & 5 deletions web/e2e/stories/app-create-template.mock.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -43,15 +43,15 @@ test.describe('App create from a business template @create', () => {
})

const repository = writes.repository?.body
if (!repository || typeof repository !== 'object' || !('template' in repository) || !('sourceConnectionId' in repository) || !('destination' in repository)) {
throw new Error('Template create did not send its source connection, template, and destination')
if (!repository || typeof repository !== 'object' || !('template' in repository) || !('destinationRevision' in repository) || !('destination' in repository)) {
throw new Error('Template create did not send its template, destination and destination revision')
}
// The platform resolves owner, connection and image path; the console only binds the revision it showed.
expect(repository).toEqual({
sourceConnectionId: 'github-source-207cb7ce',
registryConnectionRef: 'ghcr-acme',
template: { repository: 'appthrust/template-biz-inventory', ref: 'main' },
destination: { repositoryName: slug, visibility: 'private' },
image: { imagePath: `ghcr.io/acme/${slug}`, tagFilter: 'edge-', versioningScheme: 'Alphabetical' },
destinationRevision: expect.stringMatching(/^rev-github-source-207cb7ce-/),
image: { tagFilter: 'edge-', versioningScheme: 'Alphabetical' },
build: { mode: 'observed-image', ref: 'main' },
})

Expand Down
16 changes: 13 additions & 3 deletions web/src/api/client.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,13 +4,19 @@
// The console is served same-origin with a reverse proxy: `/api/*` goes to the
// Platform API, and the Authorization header is attached by the OIDC edge
// (Envoy) in front of the console. The SPA never sees or requests a token.
import { platformApiRouter } from '@appthrust/api-schema/platform-api-router'
import { platformApiRouter as publishedPlatformApiRouter } from '@appthrust/api-schema/platform-api-router'
import { createORPCClient, ORPCError } from '@orpc/client'
import type { ContractRouterClient } from '@orpc/contract'
import { OpenAPILink } from '@orpc/openapi-client/fetch'
import { platformApiRepositoryDestinationsRouter } from './repository-destination'
import { assertSessionActive, checkSessionResponse } from './session-expiry'

type PlatformClientContext = { idempotencyKey?: string }
// The repository-destination routes are not in the published contract yet
// (see ./repository-destination); drop this spread when the package is bumped.
const platformApiRouter = { ...publishedPlatformApiRouter, ...platformApiRepositoryDestinationsRouter }

/** `onResponse` sees the raw answer (for headers such as `ETag`) before the body is decoded. */
type PlatformClientContext = { idempotencyKey?: string; onResponse?: (response: Response) => void }
export type PlatformApiClient = ContractRouterClient<typeof platformApiRouter, PlatformClientContext>

const READ_TIMEOUT_MS = 8_000
Expand All @@ -33,7 +39,11 @@ export async function platformFetch(request: Request): Promise<Response> {
const link = new OpenAPILink<PlatformClientContext>(platformApiRouter, {
url: () => window.location.origin,
headers: ({ context }) => context.idempotencyKey ? { 'Idempotency-Key': context.idempotencyKey } : {},
fetch: (request) => platformFetch(request),
fetch: async (request, _init, options) => {
const response = await platformFetch(request)
options.context?.onResponse?.(response)
return response
},
})

export const client: PlatformApiClient = createORPCClient(link)
Expand Down
12 changes: 10 additions & 2 deletions web/src/api/queries.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -130,10 +130,14 @@ test('create connections: source connections are validated at the boundary, regi
usedBy: [] as Domain['usedBy'],
} as const
const calls: string[] = []
const repositoryDestination = {
state: 'Resolved', source: 'Project', revision: 'rev-1',
destination: { connectionId: 'github-source-1', provider: 'github', displayName: 'Acme', repositoryNamespace: 'acme', registryNamespace: 'ghcr.io/acme' },
} as const
const api = {
platformApiListProjectGitHubSourceConnections: mock(async (input: { projectId: string }) => {
calls.push(`sources:${input.projectId}`)
return { data: [ready] }
return { data: [ready], repositoryDestination } as unknown
}),
platformApiListRegistryConnections: mock(async () => {
calls.push('registries')
Expand All @@ -146,9 +150,13 @@ test('create connections: source connections are validated at the boundary, regi
}
const query = createConnectionsQuery('pj-1', api as never)
expect(query.queryKey).toEqual(['create-connections', 'pj-1'])
expect(await query.queryFn()).toEqual({ sources: [ready], registries: [registry], domains: [domain] })
expect(await query.queryFn()).toEqual({ sources: [ready], registries: [registry], domains: [domain], destination: repositoryDestination })
expect(calls.sort()).toEqual(['domains:pj-1', 'registries', 'sources:pj-1'])

// An answer without a valid resolution leaves the destination unknown, not the whole query failed.
const withoutDestination = { ...api, platformApiListProjectGitHubSourceConnections: mock(async () => ({ data: [ready], repositoryDestination: { state: 'Resolved' } })) }
expect((await createConnectionsQuery('pj-1', withoutDestination as never).queryFn()).destination).toBeUndefined()

const pending = { ...domain, usableForPublish: false, delegationReady: false, publishUnavailableReason: 'ProjectGatewayPending' }
const interval = query.refetchInterval as unknown as (query: unknown) => number | false
expect(typeof interval).toBe('function')
Expand Down
44 changes: 39 additions & 5 deletions web/src/api/queries.ts
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,11 @@ import {
useSuspenseQuery,
} from '@tanstack/react-query'
import { client, type PlatformApiClient } from './client'
import {
ProjectGitHubSourceConnectionListResponse,
type RepositoryDestinationResolution,
type RepositoryDestinationSettings,
} from './repository-destination'
import { ORPCError } from '@orpc/client'
import { ConsoleApiError, consoleApi, consoleKeys, isConsoleError, type AppListParams, type DirectoryAppsParams } from './console'
import { isPlatformNotFound, isTransientError, platformErrorEnvelope } from './platform-error'
Expand Down Expand Up @@ -255,6 +260,7 @@ export const keys = {
['logs', projectId, componentId, branchId] as const,
sourceRepositories: (projectId: string) => ['application-source-repositories', projectId] as const,
createConnections: (projectId: string) => ['create-connections', projectId] as const,
repositoryDestinationSettings: (projectId: string) => ['repository-destination-settings', projectId] as const,
creationTools: (projectId: string) => ['creation-tools', projectId] as const,
githubRepositories: (projectId: string, connectionId: string, q: string) =>
['github-repositories', projectId, connectionId, q] as const,
Expand Down Expand Up @@ -405,15 +411,19 @@ export interface CreateConnections {
sources: GitHubSourceConnection[]
registries: RegistryConnection[]
domains: Domain[]
/** Where new repositories go; undefined when the answer carries no valid resolution. */
destination: RepositoryDestinationResolution | undefined
}

export const CREATE_CONNECTIONS_POLL_MS = 10_000

/**
* The project's GitHub source connections (contract output is ZodTypeAny, so it
* is validated at the boundary), the reusable registry connections (the
* Platform API has no project-scoped registry-connections route) and the
* project's domain inventory (the publish base for the app URL).
* The project's GitHub source connections and the platform's resolved
* repository destination (contract output is ZodTypeAny, so both are validated
* at the boundary; a missing resolution leaves adoption and image creation
* usable), the reusable registry connections (the Platform API has no
* project-scoped registry-connections route) and the project's domain
* inventory (the publish base for the app URL).
*/
export function createConnectionsQuery(
projectId: string,
Expand All @@ -432,7 +442,8 @@ export function createConnectionsQuery(
])
// Only the items matter here; the pagination envelope is not validated.
const parsed = GitHubSourceConnectionListResponse.pick({ data: true }).parse(sources)
return { sources: parsed.data, registries: registries.data, domains: domains.data }
const resolution = ProjectGitHubSourceConnectionListResponse.pick({ repositoryDestination: true }).safeParse(sources)
return { sources: parsed.data, registries: registries.data, domains: domains.data, destination: resolution.success ? resolution.data.repositoryDestination : undefined }
},
refetchInterval: (query: { state: { data: CreateConnections | undefined } }) =>
publishDomainPending(query.state.data?.domains ?? []) ? CREATE_CONNECTIONS_POLL_MS : false,
Expand All @@ -443,6 +454,29 @@ export function useCreateConnections(projectId: string) {
return useQuery(createConnectionsQuery(projectId))
}

/** The Project's declared default destination and the ETag its PUT must send back as `If-Match`. */
export interface RepositoryDestinationSettingsRead {
settings: RepositoryDestinationSettings
etag: string | undefined
}

export function repositoryDestinationSettingsQuery(
projectId: string,
api: Pick<PlatformApiClient, 'platformApiGetProjectRepositoryDestinationSettings'> = client
) {
return queryOptions({
queryKey: keys.repositoryDestinationSettings(projectId),
queryFn: async (): Promise<RepositoryDestinationSettingsRead> => {
let etag: string | undefined
const settings = await api.platformApiGetProjectRepositoryDestinationSettings({ projectId }, {
context: { onResponse: (response) => { etag = response.headers.get('ETag') ?? undefined } },
})
return { settings, etag }
},
refetchInterval: false,
})
}

export function useSourceRepositories(projectId: string) {
return useQuery({
queryKey: keys.sourceRepositories(projectId),
Expand Down
121 changes: 121 additions & 0 deletions web/src/api/repository-destination.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,121 @@
// TEMPORARY MIRROR — delete this file when web/package.json moves
// `@appthrust/api-schema` past 0.1.4 to the release that contains the APT-799
// repository-destination contract (appthrust/platform c6d6b2dd85,
// dashboard/packages/api-schema/src/schemas/platform-api/repository-destinations.ts
// and platform-api-router-repository-destinations.ts). Every export below has
// the same name and shape there; importers switch to
// `@appthrust/api-schema/platform-api` and client.ts drops the router spread.
// Only the Project routes the console calls are mirrored; the environment
// settings routes are edited in the Dashboard, never here.
import {
type ApplicationSourceRepositoryValidation as PublishedSourceRepositoryValidation,
type CreateApplicationSourceRepositoryRequest as PublishedCreateSourceRepositoryRequest,
DnsLabelName,
ErrorEnvelope,
GitHubSourceConnectionListResponse,
ProjectScopedParams,
PublicOperation,
} from '@appthrust/api-schema/platform-api'
import { oc } from '@orpc/contract'
import { z } from 'zod'

export const RepositoryDestination = z.object({
connectionId: DnsLabelName,
provider: z.string(),
displayName: z.string(),
repositoryNamespace: z.string(),
registryNamespace: z.string(),
imageAccessId: DnsLabelName.optional(),
})
export type RepositoryDestination = z.infer<typeof RepositoryDestination>

// Open string: clients must display unknown reasons using message and settingsUrl.
export const RepositoryDestinationBlockedReason = z.string()

export const RepositoryDestinationResolution = z.discriminatedUnion('state', [
z.object({
state: z.literal('Resolved'),
source: z.enum(['Project', 'Environment', 'SingleConnection']),
revision: z.string(),
destination: RepositoryDestination,
}),
z.object({
state: z.literal('Blocked'),
reason: RepositoryDestinationBlockedReason,
message: z.string(),
candidateIds: z.array(DnsLabelName).optional(),
settingsUrl: z.string().optional(),
}),
])
export type RepositoryDestinationResolution = z.infer<typeof RepositoryDestinationResolution>

export const RepositoryDestinationSelection = z.object({
connectionId: DnsLabelName,
imageAccessId: DnsLabelName.optional(),
})
export type RepositoryDestinationSelection = z.infer<typeof RepositoryDestinationSelection>

export const RepositoryDestinationSettings = z.object({
defaultDestination: RepositoryDestinationSelection.nullable(),
})
export type RepositoryDestinationSettings = z.infer<typeof RepositoryDestinationSettings>

export const PutRepositoryDestinationSettingsRequest = RepositoryDestinationSettings
export type PutRepositoryDestinationSettingsRequest = z.infer<typeof PutRepositoryDestinationSettingsRequest>

export const RepositoryDestinationSettingsMutationResponse = z.object({
resource: RepositoryDestinationSettings,
operation: PublicOperation,
})
export type RepositoryDestinationSettingsMutationResponse = z.infer<typeof RepositoryDestinationSettingsMutationResponse>

export const ProjectGitHubSourceConnectionListResponse = GitHubSourceConnectionListResponse.extend({
repositoryDestination: RepositoryDestinationResolution,
})
export type ProjectGitHubSourceConnectionListResponse = z.infer<typeof ProjectGitHubSourceConnectionListResponse>

/** The published request plus the new `destinationRevision`; `image.imagePath` is already optional there. */
export type CreateApplicationSourceRepositoryRequest = PublishedCreateSourceRepositoryRequest & {
destinationRevision?: string
}

/** The published validation answer plus the server-computed destination fields. */
export type ApplicationSourceRepositoryValidation = PublishedSourceRepositoryValidation & {
repositoryDestination: RepositoryDestinationResolution
repositoryUrl?: string
imagePath?: string
tagFilter?: string
versioningScheme?: 'Alphabetical' | 'SemVer'
}

const headers = z.object({
'Idempotency-Key': z.string().min(1),
'If-Match': z.string().min(1),
})
const errors = {
BAD_REQUEST: { status: 400, data: ErrorEnvelope },
UNAUTHORIZED: { status: 401, data: ErrorEnvelope },
FORBIDDEN: { status: 403, data: ErrorEnvelope },
NOT_FOUND: { status: 404, data: ErrorEnvelope },
CONFLICT: { status: 409, data: ErrorEnvelope },
PRECONDITION_FAILED: { status: 412, data: ErrorEnvelope },
SERVICE_UNAVAILABLE: { status: 503, data: ErrorEnvelope },
} as const

export const platformApiRepositoryDestinationsRouter = {
platformApiGetProjectRepositoryDestination: oc
.route({ method: 'GET', path: '/api/v1/projects/{projectId}/repository-destination' })
.input(ProjectScopedParams)
.output(RepositoryDestinationResolution)
.errors(errors),
platformApiGetProjectRepositoryDestinationSettings: oc
.route({ method: 'GET', path: '/api/v1/projects/{projectId}/repository-destination-settings' })
.input(ProjectScopedParams)
.output(RepositoryDestinationSettings)
.errors(errors),
platformApiPutProjectRepositoryDestinationSettings: oc
.route({ method: 'PUT', path: '/api/v1/projects/{projectId}/repository-destination-settings', inputStructure: 'detailed' })
.input(z.object({ params: ProjectScopedParams, headers, body: PutRepositoryDestinationSettingsRequest }))
.output(RepositoryDestinationSettingsMutationResponse)
.errors(errors),
}
40 changes: 38 additions & 2 deletions web/src/copy/apps.en.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,23 @@ import type { apps, reasonCopy, reasonHelp } from './apps'

const PREREQUISITE = { github: 'GitHub connection', registry: 'app storage', domain: 'domain for publishing' }

const DESTINATION_REASON: Record<string, string> = {
NotConfigured: 'No GitHub connection can create new code locations.',
Ambiguous: 'Several GitHub connections can create code locations, and none is chosen as the destination.',
NotFound: 'The GitHub connection chosen as the destination no longer exists.',
Deleting: 'The GitHub connection chosen as the destination is being deleted.',
NotReady: 'The destination GitHub connection is not ready yet.',
MissingCapability: 'The destination GitHub connection is not allowed to create code locations.',
NotVisible: 'This project cannot use the destination GitHub connection.',
UnsupportedProvider: 'The destination connection cannot create code locations automatically.',
ImageAccessNotConfigured: 'No app storage matches the destination.',
ImageAccessAmbiguous: 'Several app storages match the destination, and none is chosen.',
ImageAccessNotReady: 'The destination app storage is not ready yet.',
DestinationMismatch: 'The requested location does not match the destination set on the platform.',
DestinationChanged: 'The destination for new code locations has changed.',
Unavailable: 'Could not check where new code locations are created.',
}

export const appsEn: typeof apps = {
status: {
live: 'Live',
Expand Down Expand Up @@ -114,7 +131,6 @@ export const appsEn: typeof apps = {
template: { title: 'Blank app', body: 'Build the contents with AI later.' },
templateUnavailable: 'This template is unavailable right now; ask your administrator for help.',
templateNotAdmitted: (repository: string) => `${repository} is unavailable; ask your administrator to allow it in the GitHub connection.`,
templateNoConnection: 'Ask your administrator to set up a GitHub connection that supports templates.',
existingUnavailable: 'Creating an app from your code is unavailable right now; ask your administrator for help.',
aiUnavailable: 'Building with AI is unavailable right now; ask your administrator for help.',
},
Expand All @@ -139,6 +155,26 @@ export const appsEn: typeof apps = {
prerequisite: PREREQUISITE,
missing: (items: readonly (keyof typeof PREREQUISITE)[]) =>
`Ask your administrator to set up: ${items.map((item) => PREREQUISITE[item]).join(', ')}.`,
destination: {
title: 'New code location',
repository: (path: string) => `Created on GitHub at ${path}.`,
image: (path: string) => `App storage: ${path}`,
source: {
Project: 'This destination is set for this project.',
Environment: 'This destination is the platform-wide default.',
SingleConnection: 'Only one GitHub connection is available, so it is used.',
},
problem: (reason: string, message: string) => DESTINATION_REASON[reason] ?? (message || DESTINATION_REASON.Unavailable!),
askAdmin: ' Ask your administrator to set the destination for new code locations.',
fixHere: ' You can fix this under Platform settings → Destination for new code locations.',
openSetting: 'Open destination settings',
blockedTitle: 'New code locations cannot be created',
changedTitle: 'The destination for new code locations has changed',
moved: (before: string, after: string) => `The destination changed from ${before} to ${after}. Check the details below and create again.`,
refreshed: 'The destination details were refreshed. Check the details below and create again.',
nowBlocked: 'No destination is available right now, so the app cannot be created. ',
retryAfterFix: ' Fix the destination setting, then try again.',
},
advanced: {
toggle: 'Advanced',
lead: 'Run an existing container image as it is.',
Expand Down Expand Up @@ -198,7 +234,7 @@ export const appsEn: typeof apps = {
submit: 'Create',
retry: 'Try again',
resume: 'Continue building',
labels: { description: 'Description', tags: 'Tags', repository: 'Code location', template: 'Template' },
labels: { description: 'Description', tags: 'Tags', repository: 'Code location', template: 'Template', destination: 'New code location' },
},
phase: {
draft: 'Registering the app…',
Expand Down
Loading
Loading