@cat-factory/app 0.260.1 → 0.261.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (32) hide show
  1. package/app/components/auth/LoginScreen.vue +4 -3
  2. package/app/components/settings/ConnectionTestVerdict.vue +62 -0
  3. package/app/components/settings/InfraHandlersConfigurator.logic.spec.ts +64 -0
  4. package/app/components/settings/InfraHandlersConfigurator.logic.ts +41 -0
  5. package/app/components/settings/InfraHandlersConfigurator.vue +36 -9
  6. package/app/components/settings/KubernetesEngineForm.vue +24 -7
  7. package/app/components/settings/KubernetesEnvironmentForm.vue +20 -7
  8. package/app/components/settings/McpAuthorizeScreen.vue +262 -0
  9. package/app/components/settings/ProviderConnectionTab.vue +3 -7
  10. package/app/components/settings/ProviderManifestEditor.vue +3 -7
  11. package/app/composables/api/mcpAuthorization.ts +30 -0
  12. package/app/composables/useApi.ts +2 -0
  13. package/app/composables/useServiceAccountTokenProblem.ts +52 -0
  14. package/app/pages/mcp-authorize.vue +7 -0
  15. package/app/stores/auth/session.ts +14 -2
  16. package/app/stores/ui/k3sDeepLink.spec.ts +79 -0
  17. package/app/stores/ui/modals.ts +25 -2
  18. package/app/types/providerConnections.ts +9 -0
  19. package/app/utils/connectionFailures.ts +32 -0
  20. package/app/utils/postSignIn.spec.ts +29 -0
  21. package/app/utils/postSignIn.ts +29 -0
  22. package/i18n/locales/de.json +57 -0
  23. package/i18n/locales/en.json +57 -0
  24. package/i18n/locales/es.json +57 -0
  25. package/i18n/locales/fr.json +57 -0
  26. package/i18n/locales/he.json +57 -0
  27. package/i18n/locales/it.json +57 -0
  28. package/i18n/locales/ja.json +57 -0
  29. package/i18n/locales/pl.json +57 -0
  30. package/i18n/locales/tr.json +57 -0
  31. package/i18n/locales/uk.json +57 -0
  32. package/package.json +2 -2
@@ -0,0 +1,262 @@
1
+ <script setup lang="ts">
2
+ import { computed, onMounted, ref } from 'vue'
3
+ import {
4
+ MCP_AUTHORIZATION_REQUEST_INVALID,
5
+ PUBLIC_API_SCOPES,
6
+ type PublicApiScope,
7
+ } from '@cat-factory/contracts'
8
+ import { apiErrorEnvelope, apiErrorReason } from '~/composables/api/errors'
9
+
10
+ // The consent screen an MCP host's authorization request lands on
11
+ // (`/mcp-authorize?request=…`), reached by a redirect from `GET /oauth/authorize`.
12
+ //
13
+ // A page in the APP rather than a screen the backend renders, and that is the security shape of
14
+ // this flow: the authorization endpoint is a top-level navigation a third party triggers, carrying
15
+ // no bearer token, so a screen served there could never say WHO is approving. Here the session is
16
+ // the app's own, and the two calls this page makes are ordinary gated API where the board choice
17
+ // and its `secrets.manage` check actually run.
18
+ //
19
+ // Not a public route: an expired session renders the login screen on this same URL, and once the
20
+ // person signs in the query string is still here and the flow continues (`postSignInUrl`, which
21
+ // LoginScreen reloads to, exists to keep it). That is correct rather than a gap, and it is also how
22
+ // an SSO deployment gets its identity provider into a flow that otherwise has no idea who anyone
23
+ // is.
24
+
25
+ const api = useApi()
26
+ const { t } = useI18n()
27
+
28
+ type Screen = 'loading' | 'deciding' | 'submitting' | 'failed'
29
+
30
+ const screen = ref<Screen>('loading')
31
+ const detail = ref<string | null>(null)
32
+ /**
33
+ * A refusal that did NOT consume the request, shown beside the choices rather than instead of
34
+ * them. See `decide`: the two failures are answered differently because only one of them ends the
35
+ * flow.
36
+ */
37
+ const decisionError = ref<string | null>(null)
38
+ const clientName = ref('')
39
+ const redirectOrigin = ref('')
40
+ const workspaces = ref<{ label: string; value: string }[]>([])
41
+ const workspaceId = ref<string | undefined>(undefined)
42
+ /**
43
+ * Starts at the FLOOR of the ladder and is replaced by the server's `defaultScope` once the request
44
+ * resolves. The screen never preselects from the host's own ask: an unauthenticated registration
45
+ * can name any scope, so the server clamps it (`consentDefaultScope`) and this page renders what it
46
+ * was given. Least privilege before that answer arrives, in case a render ever beats it.
47
+ */
48
+ const scope = ref<PublicApiScope>('read')
49
+ /** What the host asked for, when the server preselected something else. Shown, never applied. */
50
+ const requestedScope = ref<PublicApiScope | null>(null)
51
+
52
+ const sealedRequest = computed(() =>
53
+ typeof window === 'undefined'
54
+ ? ''
55
+ : (new URLSearchParams(window.location.search).get('request') ?? ''),
56
+ )
57
+
58
+ /**
59
+ * The ladder, as choices. Every rung is offered rather than a curated subset: the rungs are what
60
+ * the surface itself enforces, and hiding one would leave a host that genuinely needs it unable to
61
+ * be granted it from the screen built for granting.
62
+ */
63
+ const scopeItems = computed(() =>
64
+ PUBLIC_API_SCOPES.map((value) => ({
65
+ value,
66
+ label: t(`mcpAuthorize.scope.${value}.label`),
67
+ description: t(`mcpAuthorize.scope.${value}.description`),
68
+ })),
69
+ )
70
+
71
+ onMounted(async () => {
72
+ if (!sealedRequest.value) {
73
+ screen.value = 'failed'
74
+ detail.value = t('mcpAuthorize.error.noRequest')
75
+ return
76
+ }
77
+ try {
78
+ const [request, boards] = await Promise.all([
79
+ api.describeMcpAuthorization(sealedRequest.value),
80
+ api.listWorkspaces(),
81
+ ])
82
+ clientName.value = request.clientName
83
+ redirectOrigin.value = request.redirectOrigin
84
+ scope.value = request.defaultScope
85
+ // Only worth saying when the two differ: identical values would be a line telling a person
86
+ // that the thing in front of them is the thing in front of them.
87
+ requestedScope.value =
88
+ request.requestedScope && request.requestedScope !== request.defaultScope
89
+ ? request.requestedScope
90
+ : null
91
+ workspaces.value = boards.map((board) => ({ label: board.name, value: board.id }))
92
+ workspaceId.value = workspaces.value[0]?.value
93
+ screen.value = 'deciding'
94
+ } catch (e) {
95
+ // Terminal whatever the cause: with no request to describe there is nothing to decide, and the
96
+ // person is told to start again from the host, which is the only place a new one comes from.
97
+ screen.value = 'failed'
98
+ detail.value = apiErrorEnvelope(e)?.message ?? t('mcpAuthorize.error.expired')
99
+ }
100
+ })
101
+
102
+ async function decide(decision: 'approve' | 'deny') {
103
+ if (decision === 'approve' && !workspaceId.value) return
104
+ screen.value = 'submitting'
105
+ decisionError.value = null
106
+ try {
107
+ const result = await api.decideMcpAuthorization(
108
+ decision === 'approve'
109
+ ? {
110
+ decision,
111
+ request: sealedRequest.value,
112
+ workspaceId: workspaceId.value as string,
113
+ scope: scope.value,
114
+ }
115
+ : { decision, request: sealedRequest.value },
116
+ )
117
+ // A full navigation, never a router push: the destination is the HOST's own callback, which is
118
+ // waiting for a browser to arrive at it with the code on the query string.
119
+ if (typeof window !== 'undefined') window.location.assign(result.redirectTo)
120
+ } catch (e) {
121
+ // Two outcomes, because two things can be wrong and only one of them ends the flow. The sealed
122
+ // request gone is TERMINAL: nothing on this page can mint another, so it says so and offers the
123
+ // way out. Anything else (this board is one the person cannot mint a key on, it disappeared,
124
+ // the deployment hiccuped) leaves the request valid and this screen the only place the decision
125
+ // can be made, so dropping to a dead end over it would strand a person who has a board they
126
+ // COULD have picked one dropdown away.
127
+ if (apiErrorReason(e) === MCP_AUTHORIZATION_REQUEST_INVALID) {
128
+ screen.value = 'failed'
129
+ detail.value = apiErrorEnvelope(e)?.message ?? t('mcpAuthorize.error.expired')
130
+ return
131
+ }
132
+ screen.value = 'deciding'
133
+ decisionError.value = apiErrorEnvelope(e)?.message ?? t('mcpAuthorize.error.failed')
134
+ }
135
+ }
136
+
137
+ function backToApp() {
138
+ if (typeof window !== 'undefined') window.location.assign('/')
139
+ }
140
+ </script>
141
+
142
+ <template>
143
+ <div
144
+ class="flex min-h-screen w-screen items-center justify-center bg-slate-950 p-4 text-slate-100"
145
+ data-testid="mcp-authorize"
146
+ >
147
+ <div
148
+ class="w-full max-w-md rounded-xl border border-slate-800 bg-slate-900/80 p-8 backdrop-blur"
149
+ >
150
+ <template v-if="screen === 'loading'">
151
+ <UIcon name="i-lucide-loader" class="mx-auto h-10 w-10 animate-spin text-indigo-400" />
152
+ </template>
153
+
154
+ <template v-else-if="screen === 'failed'">
155
+ <UIcon name="i-lucide-alert-triangle" class="mx-auto mb-3 h-10 w-10 text-red-400" />
156
+ <h1
157
+ class="mb-1 text-center text-lg font-semibold text-white"
158
+ data-testid="mcp-authorize-failed"
159
+ >
160
+ {{ t('mcpAuthorize.error.title') }}
161
+ </h1>
162
+ <p class="mb-6 text-center text-sm break-words text-slate-400">{{ detail }}</p>
163
+ <UButton block color="neutral" variant="subtle" @click="backToApp">
164
+ {{ t('mcpAuthorize.back') }}
165
+ </UButton>
166
+ </template>
167
+
168
+ <template v-else>
169
+ <UIcon name="i-lucide-plug-zap" class="mx-auto mb-3 h-10 w-10 text-indigo-400" />
170
+ <h1 class="mb-1 text-center text-lg font-semibold text-white">
171
+ {{ t('mcpAuthorize.title', { client: clientName }) }}
172
+ </h1>
173
+ <!-- The origin is the one fact here an attacker cannot choose: it was matched against what
174
+ the client registered before this screen was ever reached. The name beside it is a
175
+ stranger's own words, so the copy presents it as a claim rather than as identity.
176
+ The copy reads "It says it is {client}, and …" in every locale, so BOTH holes have to
177
+ be filled: an unpassed `client` renders a sentence naming nobody, on the one screen
178
+ whose whole subject is who is asking. -->
179
+ <p class="mb-6 text-center text-sm text-slate-400">
180
+ {{ t('mcpAuthorize.subtitle', { client: clientName, origin: redirectOrigin }) }}
181
+ </p>
182
+
183
+ <div v-if="!workspaces.length" class="mb-6 text-center text-sm text-amber-300">
184
+ {{ t('mcpAuthorize.noWorkspaces') }}
185
+ </div>
186
+
187
+ <template v-else>
188
+ <UFormField :label="t('mcpAuthorize.workspace.label')" class="mb-4">
189
+ <USelect
190
+ v-model="workspaceId"
191
+ :items="workspaces"
192
+ value-key="value"
193
+ class="w-full"
194
+ data-testid="mcp-authorize-workspace"
195
+ />
196
+ </UFormField>
197
+
198
+ <UFormField
199
+ :label="t('mcpAuthorize.scopeLabel')"
200
+ :description="t('mcpAuthorize.scopeHint')"
201
+ :class="requestedScope ? 'mb-2' : 'mb-6'"
202
+ >
203
+ <URadioGroup
204
+ v-model="scope"
205
+ :items="scopeItems"
206
+ value-key="value"
207
+ data-testid="mcp-authorize-scope"
208
+ />
209
+ </UFormField>
210
+
211
+ <!-- Only when the host asked for something other than what is preselected. Anyone can
212
+ register a client and ask for `admin`, so the ask is reported as a fact ABOUT the
213
+ host rather than acted on: raising the grant stays a thing a person does. -->
214
+ <p
215
+ v-if="requestedScope"
216
+ class="mb-6 text-xs text-amber-300"
217
+ data-testid="mcp-authorize-requested-scope"
218
+ >
219
+ {{
220
+ t('mcpAuthorize.requestedScope', {
221
+ client: clientName,
222
+ scope: t(`mcpAuthorize.scope.${requestedScope}.label`),
223
+ })
224
+ }}
225
+ </p>
226
+ </template>
227
+
228
+ <p
229
+ v-if="decisionError"
230
+ class="mb-4 text-center text-sm break-words text-red-400"
231
+ data-testid="mcp-authorize-decision-error"
232
+ >
233
+ {{ decisionError }}
234
+ </p>
235
+
236
+ <div class="flex gap-2">
237
+ <UButton
238
+ block
239
+ color="neutral"
240
+ variant="subtle"
241
+ :disabled="screen === 'submitting'"
242
+ @click="decide('deny')"
243
+ >
244
+ {{ t('mcpAuthorize.deny') }}
245
+ </UButton>
246
+ <UButton
247
+ block
248
+ color="primary"
249
+ :loading="screen === 'submitting'"
250
+ :disabled="!workspaceId"
251
+ data-testid="mcp-authorize-approve"
252
+ @click="decide('approve')"
253
+ >
254
+ {{ t('mcpAuthorize.approve') }}
255
+ </UButton>
256
+ </div>
257
+
258
+ <p class="mt-4 text-center text-xs text-slate-500">{{ t('mcpAuthorize.revokeHint') }}</p>
259
+ </template>
260
+ </div>
261
+ </div>
262
+ </template>
@@ -15,6 +15,7 @@ import { computed, ref, toRaw, watch } from 'vue'
15
15
  import type { ConnectionTestResult } from '@cat-factory/contracts'
16
16
  import type { ProviderConfigField, ProviderConnectionKind } from '~/types/providerConnections'
17
17
  import ConnectionWarnings from '~/components/settings/ConnectionWarnings.vue'
18
+ import ConnectionTestVerdict from '~/components/settings/ConnectionTestVerdict.vue'
18
19
  import ProvisioningLogsDrawer from '~/components/provisioning/ProvisioningLogsDrawer.vue'
19
20
  import ProviderManifestEditor from '~/components/settings/ProviderManifestEditor.vue'
20
21
  import KubernetesEnvironmentForm from '~/components/settings/KubernetesEnvironmentForm.vue'
@@ -498,7 +499,7 @@ function fieldHelp(key: string): string | undefined {
498
499
  />
499
500
  </UFormField>
500
501
 
501
- <div v-if="descriptor.supportsTest" class="flex items-center gap-2">
502
+ <div v-if="descriptor.supportsTest" class="space-y-1.5">
502
503
  <UButton
503
504
  color="neutral"
504
505
  variant="soft"
@@ -509,12 +510,7 @@ function fieldHelp(key: string): string | undefined {
509
510
  >
510
511
  {{ t('settings.providerConnection.test.button') }}
511
512
  </UButton>
512
- <span v-if="testResult && testResult.ok" class="text-xs text-emerald-400">
513
- {{ testResult.message ?? t('settings.providerConnection.test.ok') }}
514
- </span>
515
- <span v-else-if="testResult" class="text-xs text-rose-400">
516
- {{ testResult.message ?? t('settings.providerConnection.test.failed') }}
517
- </span>
513
+ <ConnectionTestVerdict :result="testResult" />
518
514
  </div>
519
515
 
520
516
  <ConnectionWarnings :warnings="testResult?.warnings" />
@@ -20,6 +20,7 @@ import { environmentManifestSchema, runnerPoolManifestSchema } from '@cat-factor
20
20
  import type { ConnectionTestResult } from '@cat-factory/contracts'
21
21
  import type { ProviderConnectionKind } from '~/types/providerConnections'
22
22
  import ConnectionWarnings from '~/components/settings/ConnectionWarnings.vue'
23
+ import ConnectionTestVerdict from '~/components/settings/ConnectionTestVerdict.vue'
23
24
  import SecretInput from '~/components/common/SecretInput.vue'
24
25
 
25
26
  const props = defineProps<{
@@ -267,7 +268,7 @@ function onSave() {
267
268
  </UFormField>
268
269
  </div>
269
270
 
270
- <div v-if="supportsTest" class="flex items-center gap-2">
271
+ <div v-if="supportsTest" class="space-y-1.5">
271
272
  <UButton
272
273
  color="neutral"
273
274
  variant="soft"
@@ -280,12 +281,7 @@ function onSave() {
280
281
  >
281
282
  {{ t('settings.providerConnection.test.button') }}
282
283
  </UButton>
283
- <span v-if="testResult && testResult.ok" class="text-xs text-emerald-400">
284
- {{ testResult.message ?? t('settings.providerConnection.test.ok') }}
285
- </span>
286
- <span v-else-if="testResult" class="text-xs text-rose-400">
287
- {{ testResult.message ?? t('settings.providerConnection.test.failed') }}
288
- </span>
284
+ <ConnectionTestVerdict :result="testResult" />
289
285
  </div>
290
286
 
291
287
  <ConnectionWarnings :warnings="testResult?.warnings" />
@@ -0,0 +1,30 @@
1
+ import {
2
+ decideMcpAuthorizationContract,
3
+ describeMcpAuthorizationContract,
4
+ type McpAuthorizationDecision,
5
+ } from '@cat-factory/contracts'
6
+ import type { ApiContext } from './context'
7
+
8
+ /**
9
+ * The consent screen an MCP host's authorization request lands on.
10
+ *
11
+ * The mirror image of the tool-server OAuth calls beside it: those connect THIS deployment to
12
+ * someone else's MCP server, these let someone else's host connect to this one. Neither is
13
+ * workspace-prefixed, and for opposite reasons: there the board is sealed into the vendor's state,
14
+ * here it is what the person on the screen is choosing.
15
+ *
16
+ * Both are POSTs, the read included. The sealed request is a value the page carries rather than an
17
+ * id it looks up, and a query string would write it into browser history and every log in between.
18
+ */
19
+ export function mcpAuthorizationApi({ send }: ApiContext) {
20
+ return {
21
+ describeMcpAuthorization: (request: string) =>
22
+ send(describeMcpAuthorizationContract, { body: { request } }),
23
+
24
+ // Answers with WHERE to send the browser, rather than redirecting: a 302 on a `fetch` is
25
+ // followed by the browser without this page seeing it, which would deliver the host's callback
26
+ // an XHR instead of the navigation it is waiting for.
27
+ decideMcpAuthorization: (body: McpAuthorizationDecision) =>
28
+ send(decideMcpAuthorizationContract, { body }),
29
+ }
30
+ }
@@ -35,6 +35,7 @@ import { modelsApi } from './api/models'
35
35
  import { notificationsApi } from './api/notifications'
36
36
  import { packageRegistriesApi } from './api/packageRegistries'
37
37
  import { capabilityCredentialsApi } from './api/capabilityCredentials'
38
+ import { mcpAuthorizationApi } from './api/mcpAuthorization'
38
39
  import { toolServersApi } from './api/toolServers'
39
40
  import { preflightsApi } from './api/preflights'
40
41
  import { presetsApi } from './api/presets'
@@ -161,6 +162,7 @@ export function useApi() {
161
162
  ...testSecretsApi(ctx),
162
163
  ...packageRegistriesApi(ctx),
163
164
  ...capabilityCredentialsApi(ctx),
165
+ ...mcpAuthorizationApi(ctx),
164
166
  ...toolServersApi(ctx),
165
167
  ...previewApi(ctx),
166
168
  ...environmentsApi(ctx),
@@ -0,0 +1,52 @@
1
+ import { computed, type ComputedRef, type Ref } from 'vue'
2
+ import {
3
+ classifyServiceAccountToken,
4
+ isFatalServiceAccountTokenProblem,
5
+ type ServiceAccountTokenProblem,
6
+ } from '@cat-factory/contracts'
7
+
8
+ // Inline validation of a pasted Kubernetes ServiceAccount token, shared by the two kube connect
9
+ // forms so they cannot drift on what a bad paste is or on what to say about it.
10
+ //
11
+ // The rule itself is in `@cat-factory/contracts` because the backend enforces the same one (see
12
+ // `KubernetesApiClient`), and this is the SPA half of the split CLAUDE.md prescribes: the backend
13
+ // emits a machine-readable code, the SPA owns the translated prose. So the map below is the one
14
+ // place a problem code becomes copy.
15
+
16
+ /**
17
+ * The message key per problem, as an exhaustive `Record`: a code added to the contract union fails
18
+ * the typecheck here until it has copy, rather than rendering as a silently missing hint. The keys
19
+ * are literals for the same reason they are elsewhere in the SPA (an assembled key is invisible to
20
+ * the typed-message-key check), and they sit under the shared `providerConnection` namespace
21
+ * because both the per-type engine form and the legacy single-connection form show them.
22
+ */
23
+ const MESSAGE_KEYS: Record<ServiceAccountTokenProblem, string> = {
24
+ whitespace: 'settings.providerConnection.serviceAccountToken.whitespace',
25
+ 'base64-encoded': 'settings.providerConnection.serviceAccountToken.base64Encoded',
26
+ 'not-a-jwt': 'settings.providerConnection.serviceAccountToken.notAJwt',
27
+ }
28
+
29
+ export interface ServiceAccountTokenCheck {
30
+ /** The problem code, or null when the value looks fine (and when it is empty). */
31
+ problem: ComputedRef<ServiceAccountTokenProblem | null>
32
+ /**
33
+ * Whether the problem should BLOCK Test and Save. True only for the impossible case (whitespace
34
+ * inside the token), never for the merely-suspicious shapes: a `--token-auth-file` apiserver
35
+ * accepts an arbitrary static bearer token, and a check that cannot be sure must not be the
36
+ * thing that stops a legitimate cluster being configured.
37
+ */
38
+ blocking: ComputedRef<boolean>
39
+ /** The translated hint to render under the field, or '' when there is nothing to say. */
40
+ message: ComputedRef<string>
41
+ }
42
+
43
+ /** Classify the live value of a token field and render the verdict as translated copy. */
44
+ export function useServiceAccountTokenProblem(token: Ref<string>): ServiceAccountTokenCheck {
45
+ const { t } = useI18n()
46
+ const problem = computed(() => classifyServiceAccountToken(token.value))
47
+ return {
48
+ problem,
49
+ blocking: computed(() => !!problem.value && isFatalServiceAccountTokenProblem(problem.value)),
50
+ message: computed(() => (problem.value ? t(MESSAGE_KEYS[problem.value]) : '')),
51
+ }
52
+ }
@@ -0,0 +1,7 @@
1
+ <script setup lang="ts">
2
+ import McpAuthorizeScreen from '~/components/settings/McpAuthorizeScreen.vue'
3
+ </script>
4
+
5
+ <template>
6
+ <McpAuthorizeScreen />
7
+ </template>
@@ -1,5 +1,6 @@
1
1
  import type { Ref } from 'vue'
2
2
  import type { AuthUser } from '~/types/domain'
3
+ import { postSignInUrl } from '~/utils/postSignIn'
3
4
 
4
5
  /**
5
6
  * Shared reactive state + injected dependencies the auth-store sign-in factory closes over.
@@ -24,9 +25,20 @@ export interface AuthSessionContext {
24
25
  export function createAuthSessionActions(ctx: AuthSessionContext) {
25
26
  const { api, apiBase, token, user, autoLoginProvider } = ctx
26
27
 
27
- /** Build a post-login redirect back to the current page, with an optional invite. */
28
+ /**
29
+ * Build a post-login redirect back to the current page, with an optional invite.
30
+ *
31
+ * "The current page" includes its QUERY STRING, through the same `postSignInUrl` the credential
32
+ * forms reload to, so the round-trip through an identity provider lands where the person started
33
+ * rather than one level up. A flow that carries its whole subject there (`/mcp-authorize?request=`)
34
+ * otherwise comes back from the IdP to a page that no longer knows what it was asked, and this is
35
+ * the path an SSO deployment takes for EVERY sign-in, not an unusual one.
36
+ *
37
+ * The `invite` is dropped from the returned-to URL by that helper and named as its own parameter
38
+ * here, which is the same token travelling as itself rather than twice.
39
+ */
28
40
  function redirectTarget(invite?: string): string {
29
- const here = window.location.origin + window.location.pathname
41
+ const here = window.location.origin + postSignInUrl(window.location)
30
42
  const params = new URLSearchParams({ redirect: here })
31
43
  if (invite) params.set('invite', invite)
32
44
  return params.toString()
@@ -0,0 +1,79 @@
1
+ import { describe, it, expect, beforeEach } from 'vitest'
2
+ import { createUiModals } from '~/stores/ui/modals'
3
+
4
+ /**
5
+ * The `cat-factory k3s` CLI hand-off (`?infraSetup=local-k3s&…`), driven through the modals slice
6
+ * directly (plain refs/functions, no Pinia) exactly as the overlay-host slice tests do.
7
+ *
8
+ * What is worth pinning here is the ARRIVAL, not the parsing: the CLI's whole promise is that the
9
+ * operator lands on the one form it just filled in, and both halves of that (the tab AND the
10
+ * section anchor within it) are set in this one function. The prefill is asserted alongside
11
+ * because the deep link is the only thing that ever sets it.
12
+ */
13
+ function openWith(search: string): void {
14
+ window.history.replaceState(null, '', `/${search}`)
15
+ }
16
+
17
+ const K3S_LINK =
18
+ '?infraSetup=local-k3s&label=Local+k3s&apiServerUrl=https%3A%2F%2F127.0.0.1%3A6443' +
19
+ '&namespaceTemplate=cf-env-%7B%7BpullNumber%7D%7D&hostTemplate=%7B%7Bbranch%7D%7D.127.0.0.1.nip.io' +
20
+ '&insecureSkipTlsVerify=1'
21
+
22
+ describe('consumeK3sSetupDeepLink', () => {
23
+ beforeEach(() => {
24
+ openWith('')
25
+ })
26
+
27
+ it('opens the Test-environments tab ANCHORED on the Kubernetes section', () => {
28
+ const ui = createUiModals()
29
+ openWith(K3S_LINK)
30
+ ui.consumeK3sSetupDeepLink()
31
+
32
+ expect(ui.infrastructureOpen.value).toBe(true)
33
+ expect(ui.infrastructureTab.value).toBe('environment')
34
+ // The tab opens on the default-provision picker, so without this the operator lands above
35
+ // the form the CLI just described and has to scroll to find it.
36
+ expect(ui.infrastructureScrollTarget.value).toBe('kubernetes')
37
+ expect(ui.k3sSetupPrefill.value).toEqual({
38
+ label: 'Local k3s',
39
+ apiServerUrl: 'https://127.0.0.1:6443',
40
+ namespaceTemplate: 'cf-env-{{pullNumber}}',
41
+ hostTemplate: '{{branch}}.127.0.0.1.nip.io',
42
+ insecureSkipTlsVerify: true,
43
+ })
44
+ })
45
+
46
+ it('strips the params so a reload neither re-opens the window nor re-anchors it', () => {
47
+ const ui = createUiModals()
48
+ openWith(K3S_LINK)
49
+ ui.consumeK3sSetupDeepLink()
50
+ expect(window.location.search).toBe('')
51
+
52
+ const reloaded = createUiModals()
53
+ reloaded.consumeK3sSetupDeepLink()
54
+ expect(reloaded.infrastructureOpen.value).toBe(false)
55
+ expect(reloaded.infrastructureScrollTarget.value).toBeNull()
56
+ })
57
+
58
+ it('drops an UNCONSUMED anchor on close, so the next plain open does not scroll', () => {
59
+ // The panel clears the target once it has scrolled. Closing before it rendered (the window
60
+ // was dismissed, or the infra probe never resolved) must not leave the anchor armed.
61
+ const ui = createUiModals()
62
+ openWith(K3S_LINK)
63
+ ui.consumeK3sSetupDeepLink()
64
+ ui.closeProviderConnection()
65
+
66
+ expect(ui.infrastructureScrollTarget.value).toBeNull()
67
+ expect(ui.k3sSetupPrefill.value).toBeNull()
68
+ })
69
+
70
+ it('is a no-op for an unrelated query string', () => {
71
+ const ui = createUiModals()
72
+ openWith('?settings=default-test-env')
73
+ ui.consumeK3sSetupDeepLink()
74
+
75
+ expect(ui.infrastructureOpen.value).toBe(false)
76
+ expect(ui.infrastructureScrollTarget.value).toBeNull()
77
+ expect(window.location.search).toBe('?settings=default-test-env')
78
+ })
79
+ })
@@ -1,6 +1,10 @@
1
1
  import { ref } from 'vue'
2
2
  import type { DocumentSourceKind, InfraSetupArea, TaskSourceKind } from '~/types/domain'
3
- import type { InfrastructureTab, ProviderConnectionKind } from '~/types/providerConnections'
3
+ import type {
4
+ InfrastructureScrollTarget,
5
+ InfrastructureTab,
6
+ ProviderConnectionKind,
7
+ } from '~/types/providerConnections'
4
8
  import type { PendingContext } from '~/composables/useContextLinking'
5
9
  import {
6
10
  infraSetupDismissalKey,
@@ -737,6 +741,13 @@ function createInfraModals(resetHubReturn: ResetHubReturn) {
737
741
  // `local-k3s` connection from it; the ServiceAccount token is deliberately NOT in the link (a
738
742
  // secret in a URL leaks into history/logs), so the user still pastes it before Test → Save.
739
743
  const k3sSetupPrefill = ref<K3sSetupPrefill | null>(null)
744
+ // A one-shot deep-link anchor into a SECTION of the open tab, mirroring
745
+ // `accountSettingsScrollTarget`. The Test-environments tab opens on the default-provision
746
+ // picker and the Compose wizard, with the per-type handler sections between them, so landing an
747
+ // operator at the top of it after a `cat-factory k3s` hand-off leaves them scrolling to find the
748
+ // very form the CLI just filled in. The owning panel scrolls the section into view once and
749
+ // then calls `clearInfrastructureScrollTarget`, so a later plain open doesn't re-scroll.
750
+ const infrastructureScrollTarget = ref<InfrastructureScrollTarget | null>(null)
740
751
  // Environment setup wizard (shared-stacks slice 7): the guided detect → review → preflight →
741
752
  // trial → save flow for a service frame's `docker-compose` provisioning. `environmentWizardOpen`
742
753
  // is the modal flag; `environmentWizardFrameId` preselects the service frame the flow targets
@@ -761,8 +772,14 @@ function createInfraModals(resetHubReturn: ResetHubReturn) {
761
772
  }
762
773
  function closeProviderConnection() {
763
774
  infrastructureOpen.value = false
764
- // Drop any consumed CLI prefill so re-opening the window normally doesn't re-seed the form.
775
+ // Drop any consumed CLI prefill so re-opening the window normally doesn't re-seed the form,
776
+ // and the anchor with it: an unconsumed target (the window was closed before the section
777
+ // rendered) would otherwise scroll the next, unrelated open.
765
778
  k3sSetupPrefill.value = null
779
+ infrastructureScrollTarget.value = null
780
+ }
781
+ function clearInfrastructureScrollTarget() {
782
+ infrastructureScrollTarget.value = null
766
783
  }
767
784
  // Capture a `cat-factory k3s` deep-link (`?infraSetup=local-k3s&…`) on app load: stash the
768
785
  // non-secret connection values, open the Infrastructure window on the Test-environments tab so
@@ -786,6 +803,10 @@ function createInfraModals(resetHubReturn: ResetHubReturn) {
786
803
  }
787
804
  resetHubReturn()
788
805
  infrastructureTab.value = 'environment'
806
+ // The hand-off is about ONE form, so land on it: the Kubernetes section sits below the
807
+ // default-provision picker, far enough down the tab that an operator arriving from the CLI
808
+ // would otherwise have to go looking for the fields it just told them about.
809
+ infrastructureScrollTarget.value = 'kubernetes'
789
810
  infrastructureOpen.value = true
790
811
  for (const key of [
791
812
  'infraSetup',
@@ -840,6 +861,8 @@ function createInfraModals(resetHubReturn: ResetHubReturn) {
840
861
  infrastructureTab,
841
862
  openInfrastructure,
842
863
  k3sSetupPrefill,
864
+ infrastructureScrollTarget,
865
+ clearInfrastructureScrollTarget,
843
866
  consumeK3sSetupDeepLink,
844
867
  environmentWizardOpen,
845
868
  environmentWizardFrameId,
@@ -33,6 +33,15 @@ export type InfrastructureTab =
33
33
  | 'package-registries'
34
34
  | 'capability-credentials'
35
35
 
36
+ /**
37
+ * A SECTION within an Infrastructure tab that a deep link can land the user on, rather than at
38
+ * the top of the tab with the section to hunt for. A closed union rather than a bare string, so
39
+ * the store's setter and the panel that honours it cannot drift apart silently: today's only
40
+ * member is the `kubernetes` provision-type section the `cat-factory k3s` hand-off targets, which
41
+ * sits below the default-provision picker in a tab long enough to need scrolling.
42
+ */
43
+ export type InfrastructureScrollTarget = 'kubernetes'
44
+
36
45
  /** A workspace's provider binding, as exposed to clients (never secret values). */
37
46
  export interface ProviderConnection {
38
47
  /** The runner-backend kind for a runner-pool connection (`manifest` | `kubernetes`). */
@@ -0,0 +1,32 @@
1
+ import type { ConnectionFailureCause } from '@cat-factory/contracts'
2
+
3
+ // A connection test that never got an ANSWER reports the transport failure CLASS as a
4
+ // machine-readable `failureCause` (the backend does not localize prose), and the copy the operator
5
+ // reads lives here. The backend's own English account of the failure, including the remedy it can
6
+ // phrase with the concrete host in it, stays beside the headline as the technical detail.
7
+ //
8
+ // The exhaustive `Record<ConnectionFailureCause, …>` is the tier-2 drift guard, as in
9
+ // `connectionWarnings.ts`: a backend that adds a cause fails this typecheck until the SPA has copy
10
+ // for it, which the typed-key check cannot catch for a runtime-assembled key.
11
+
12
+ /**
13
+ * Failure class → i18n key, or `null` where there is deliberately no headline to render.
14
+ *
15
+ * `unknown` is that case, and it is the reason the values are nullable: the chain was read and
16
+ * matched nothing, so the only honest statement about it is the backend's verbatim account, which
17
+ * is then rendered as the primary line instead of a headline that would have to invent a class.
18
+ */
19
+ export const CONNECTION_FAILURE_CAUSE_KEYS: Record<ConnectionFailureCause, string | null> = {
20
+ refused: 'settings.providerConnection.test.causes.refused',
21
+ dns: 'settings.providerConnection.test.causes.dns',
22
+ timeout: 'settings.providerConnection.test.causes.timeout',
23
+ aborted: 'settings.providerConnection.test.causes.aborted',
24
+ unreachable: 'settings.providerConnection.test.causes.unreachable',
25
+ reset: 'settings.providerConnection.test.causes.reset',
26
+ 'tls-untrusted': 'settings.providerConnection.test.causes.tlsUntrusted',
27
+ 'tls-expired': 'settings.providerConnection.test.causes.tlsExpired',
28
+ 'tls-hostname': 'settings.providerConnection.test.causes.tlsHostname',
29
+ 'tls-protocol': 'settings.providerConnection.test.causes.tlsProtocol',
30
+ 'invalid-header': 'settings.providerConnection.test.causes.invalidHeader',
31
+ unknown: null,
32
+ }