@cat-factory/app 0.260.1 → 0.261.0

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.
@@ -5,6 +5,7 @@ import SecretInput from '~/components/common/SecretInput.vue'
5
5
  import type { VcsProvider } from '~/types/domain'
6
6
  import { VCS_PROVIDER_ICONS, VCS_PROVIDER_LABELS, vcsTokenCreateUrl } from '~/utils/vcs'
7
7
  import { SSO_ERROR_MESSAGE_KEYS } from '~/utils/sso'
8
+ import { postSignInUrl } from '~/utils/postSignIn'
8
9
 
9
10
  const auth = useAuthStore()
10
11
  const { t } = useI18n()
@@ -62,7 +63,7 @@ async function submitPat(provider: PatProvider) {
62
63
  patBusy.value = true
63
64
  try {
64
65
  await auth.patLogin({ provider })
65
- if (typeof window !== 'undefined') window.location.assign(window.location.pathname)
66
+ if (typeof window !== 'undefined') window.location.assign(postSignInUrl(window.location))
66
67
  } catch (e) {
67
68
  patError.value = apiErrorEnvelope(e)?.message ?? t('auth.localMode.failed')
68
69
  } finally {
@@ -105,7 +106,7 @@ async function submitPassword() {
105
106
  await auth.passwordLogin({ email: email.value, password: password.value })
106
107
  }
107
108
  // Reload so the app boots with the new session.
108
- if (typeof window !== 'undefined') window.location.assign(window.location.pathname)
109
+ if (typeof window !== 'undefined') window.location.assign(postSignInUrl(window.location))
109
110
  } catch (e) {
110
111
  error.value = apiErrorEnvelope(e)?.message ?? t('auth.login.signInFailed')
111
112
  } finally {
@@ -177,7 +178,7 @@ async function submitRemotePat() {
177
178
  remotePatBusy.value = true
178
179
  try {
179
180
  await auth.patLogin({ provider: remotePatProvider.value, token: remotePatToken.value.trim() })
180
- if (typeof window !== 'undefined') window.location.assign(window.location.pathname)
181
+ if (typeof window !== 'undefined') window.location.assign(postSignInUrl(window.location))
181
182
  } catch (e) {
182
183
  remotePatError.value = apiErrorEnvelope(e)?.message ?? t('auth.login.signInFailed')
183
184
  } finally {
@@ -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>
@@ -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,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,29 @@
1
+ import { describe, expect, it } from 'vitest'
2
+ import { postSignInUrl } from './postSignIn'
3
+
4
+ // The regression this exists for: every sign-in path reloaded to `location.pathname`, so a flow
5
+ // whose subject rides the query string lost it the moment a person signed in. The MCP consent
6
+ // screen is the one that fails hardest, and signing in first is the ordinary way a first connect
7
+ // goes, so the loss is on the common path rather than an edge of it.
8
+
9
+ describe('postSignInUrl', () => {
10
+ it('keeps the query string the destination needs', () => {
11
+ expect(postSignInUrl({ pathname: '/mcp-authorize', search: '?request=sealed-value' })).toBe(
12
+ '/mcp-authorize?request=sealed-value',
13
+ )
14
+ })
15
+
16
+ it('drops the invite token, which the signup call already spent', () => {
17
+ // Not a matter of tidiness: a consumed invite left in the address bar is a token in every
18
+ // place a URL gets pasted, and it buys the reader nothing because it no longer works.
19
+ expect(postSignInUrl({ pathname: '/', search: '?invite=tok_1' })).toBe('/')
20
+ expect(postSignInUrl({ pathname: '/', search: '?invite=tok_1&ws=ws_9' })).toBe('/?ws=ws_9')
21
+ })
22
+
23
+ it('answers a bare path unchanged, and drops a fragment', () => {
24
+ expect(postSignInUrl({ pathname: '/', search: '' })).toBe('/')
25
+ // The fragment is absent by construction: it is never read here, so a stale one would only
26
+ // scroll the freshly booted app to an anchor the previous screen owned.
27
+ expect(postSignInUrl({ pathname: '/boards', search: '?a=1' })).toBe('/boards?a=1')
28
+ })
29
+ })
@@ -0,0 +1,29 @@
1
+ /**
2
+ * Where the browser reloads to once a sign-in succeeds.
3
+ *
4
+ * Every sign-in path reloads rather than routing, because the app has to boot with the new session
5
+ * rather than patch itself around it. What it reloads TO is the question this answers, and the
6
+ * naive `location.pathname` gets it wrong: the login screen renders at whatever URL the person
7
+ * arrived at, so the query string belongs to the destination, not to the sign-in. Dropping it
8
+ * silently strands any flow that carries its subject there. The MCP consent screen is the case that
9
+ * bites hardest (`/mcp-authorize?request=<sealed>`): signing in first is the COMMON path for a
10
+ * first connect, and landing back with no `request` leaves a person looking at "this page was
11
+ * opened without an authorization request" with no way forward except restarting from the host.
12
+ *
13
+ * `invite` is the one parameter dropped, and it is dropped because it has already been SPENT: the
14
+ * signup call consumed it, so keeping it would leave a consumed token in the address bar and in
15
+ * every place a URL gets pasted. Everything else is the destination's business, not this module's,
16
+ * which is why the rule is a named exception rather than an allowlist nobody remembers to extend.
17
+ */
18
+ const SPENT_PARAMS = ['invite']
19
+
20
+ /**
21
+ * The post-sign-in URL for one location: its path, its query minus the spent parameters, and no
22
+ * fragment (nothing in this app puts state there, and a stale one would scroll to nowhere).
23
+ */
24
+ export function postSignInUrl(location: { pathname: string; search: string }): string {
25
+ const params = new URLSearchParams(location.search)
26
+ for (const spent of SPENT_PARAMS) params.delete(spent)
27
+ const query = params.toString()
28
+ return query ? `${location.pathname}?${query}` : location.pathname
29
+ }
@@ -1,4 +1,43 @@
1
1
  {
2
+ "mcpAuthorize": {
3
+ "title": "{client} verbinden?",
4
+ "subtitle": "Die Anwendung gibt an, {client} zu sein, und diese Installation leitet sie zurück an {origin}.",
5
+ "workspace": {
6
+ "label": "Board, auf dem sie handeln darf"
7
+ },
8
+ "scopeLabel": "Was sie tun darf",
9
+ "scopeHint": "Jede Stufe schließt die darüberliegenden ein.",
10
+ "scope": {
11
+ "read": {
12
+ "label": "Nur lesen",
13
+ "description": "Services, Aufgaben, Pipelines und Läufe ansehen."
14
+ },
15
+ "write": {
16
+ "label": "Lesen und schreiben",
17
+ "description": "Zusätzlich Aufgaben anlegen und starten."
18
+ },
19
+ "decide": {
20
+ "label": "Lesen, schreiben und entscheiden",
21
+ "description": "Zusätzlich die Fragen beantworten, auf die ein pausierter Lauf wartet."
22
+ },
23
+ "admin": {
24
+ "label": "Voller Zugriff",
25
+ "description": "Zusätzlich Aufgaben löschen und auf Benachrichtigungen reagieren, was einen Pull Request zusammenführen kann."
26
+ }
27
+ },
28
+ "requestedScope": "{client} hat {scope} angefragt. Wählen Sie das oben nur, wenn Sie es wirklich gewähren möchten.",
29
+ "approve": "Verbinden",
30
+ "deny": "Abbrechen",
31
+ "back": "Zurück zur App",
32
+ "noWorkspaces": "Sie haben noch kein Board, mit dem sich das verbinden ließe.",
33
+ "revokeHint": "Dabei wird ein API-Schlüssel ausgestellt, den Sie jederzeit in den Board-Einstellungen widerrufen können.",
34
+ "error": {
35
+ "title": "Diese Verbindung konnte nicht eingerichtet werden",
36
+ "noRequest": "Diese Seite wurde ohne Autorisierungsanfrage geöffnet. Starten Sie die Verbindung in Ihrem MCP-Host.",
37
+ "expired": "Diese Autorisierungsanfrage ist ungültig oder abgelaufen. Starten Sie die Verbindung in Ihrem MCP-Host erneut.",
38
+ "failed": "Die Entscheidung konnte nicht gespeichert werden. Starten Sie die Verbindung in Ihrem MCP-Host erneut."
39
+ }
40
+ },
2
41
  "settings": {
3
42
  "modelConfiguration": {
4
43
  "title": "Modellkonfiguration",
@@ -2682,6 +2682,45 @@
2682
2682
  }
2683
2683
  }
2684
2684
  },
2685
+ "mcpAuthorize": {
2686
+ "title": "Connect {client}?",
2687
+ "subtitle": "It says it is {client}, and this deployment will send it back to {origin}.",
2688
+ "workspace": {
2689
+ "label": "Board it may act on"
2690
+ },
2691
+ "scopeLabel": "What it may do",
2692
+ "scopeHint": "Each level includes the ones above it.",
2693
+ "scope": {
2694
+ "read": {
2695
+ "label": "Read only",
2696
+ "description": "See services, tasks, pipelines and runs."
2697
+ },
2698
+ "write": {
2699
+ "label": "Read and write",
2700
+ "description": "Also create and start tasks."
2701
+ },
2702
+ "decide": {
2703
+ "label": "Read, write and decide",
2704
+ "description": "Also answer the questions a parked run is waiting on."
2705
+ },
2706
+ "admin": {
2707
+ "label": "Full access",
2708
+ "description": "Also delete tasks and act on notifications, which can merge a pull request."
2709
+ }
2710
+ },
2711
+ "requestedScope": "{client} asked for {scope}. Choose it above only if you mean to grant that.",
2712
+ "approve": "Connect",
2713
+ "deny": "Cancel",
2714
+ "back": "Back to the app",
2715
+ "noWorkspaces": "You have no boards to connect this to yet.",
2716
+ "revokeHint": "This issues an API key you can revoke at any time from the board's settings.",
2717
+ "error": {
2718
+ "title": "This connection could not be set up",
2719
+ "noRequest": "This page was opened without an authorization request. Start the connection from your MCP host.",
2720
+ "expired": "This authorization request is invalid or has expired. Start the connection again from your MCP host.",
2721
+ "failed": "The decision could not be recorded. Start the connection again from your MCP host."
2722
+ }
2723
+ },
2685
2724
  "settings": {
2686
2725
  "modelConfiguration": {
2687
2726
  "title": "Model Configuration",
@@ -2575,6 +2575,45 @@
2575
2575
  }
2576
2576
  }
2577
2577
  },
2578
+ "mcpAuthorize": {
2579
+ "title": "¿Conectar {client}?",
2580
+ "subtitle": "Dice ser {client}, y esta instalación lo devolverá a {origin}.",
2581
+ "workspace": {
2582
+ "label": "Tablero en el que podrá actuar"
2583
+ },
2584
+ "scopeLabel": "Lo que podrá hacer",
2585
+ "scopeHint": "Cada nivel incluye los anteriores.",
2586
+ "scope": {
2587
+ "read": {
2588
+ "label": "Solo lectura",
2589
+ "description": "Ver servicios, tareas, pipelines y ejecuciones."
2590
+ },
2591
+ "write": {
2592
+ "label": "Lectura y escritura",
2593
+ "description": "Además, crear e iniciar tareas."
2594
+ },
2595
+ "decide": {
2596
+ "label": "Lectura, escritura y decisión",
2597
+ "description": "Además, responder a las preguntas que espera una ejecución detenida."
2598
+ },
2599
+ "admin": {
2600
+ "label": "Acceso completo",
2601
+ "description": "Además, eliminar tareas y actuar sobre notificaciones, lo que puede fusionar un pull request."
2602
+ }
2603
+ },
2604
+ "requestedScope": "{client} solicitó {scope}. Elige esa opción arriba solo si de verdad quieres concederla.",
2605
+ "approve": "Conectar",
2606
+ "deny": "Cancelar",
2607
+ "back": "Volver a la aplicación",
2608
+ "noWorkspaces": "Todavía no tienes ningún tablero al que conectarlo.",
2609
+ "revokeHint": "Esto emite una clave de API que puedes revocar cuando quieras desde los ajustes del tablero.",
2610
+ "error": {
2611
+ "title": "No se pudo establecer esta conexión",
2612
+ "noRequest": "Esta página se abrió sin una solicitud de autorización. Inicia la conexión desde tu host MCP.",
2613
+ "expired": "Esta solicitud de autorización no es válida o ha caducado. Vuelve a iniciar la conexión desde tu host MCP.",
2614
+ "failed": "No se pudo registrar la decisión. Vuelve a iniciar la conexión desde tu host MCP."
2615
+ }
2616
+ },
2578
2617
  "settings": {
2579
2618
  "modelConfiguration": {
2580
2619
  "title": "Configuración de modelos",
@@ -2575,6 +2575,45 @@
2575
2575
  }
2576
2576
  }
2577
2577
  },
2578
+ "mcpAuthorize": {
2579
+ "title": "Connecter {client} ?",
2580
+ "subtitle": "Il se présente comme {client}, et ce déploiement le renverra vers {origin}.",
2581
+ "workspace": {
2582
+ "label": "Tableau sur lequel il pourra agir"
2583
+ },
2584
+ "scopeLabel": "Ce qu'il pourra faire",
2585
+ "scopeHint": "Chaque niveau inclut les précédents.",
2586
+ "scope": {
2587
+ "read": {
2588
+ "label": "Lecture seule",
2589
+ "description": "Voir les services, les tâches, les pipelines et les exécutions."
2590
+ },
2591
+ "write": {
2592
+ "label": "Lecture et écriture",
2593
+ "description": "Et aussi créer et lancer des tâches."
2594
+ },
2595
+ "decide": {
2596
+ "label": "Lecture, écriture et décision",
2597
+ "description": "Et aussi répondre aux questions qu'attend une exécution en pause."
2598
+ },
2599
+ "admin": {
2600
+ "label": "Accès complet",
2601
+ "description": "Et aussi supprimer des tâches et agir sur les notifications, ce qui peut fusionner une pull request."
2602
+ }
2603
+ },
2604
+ "requestedScope": "{client} a demandé {scope}. Ne choisissez cette option ci-dessus que si vous voulez vraiment l'accorder.",
2605
+ "approve": "Connecter",
2606
+ "deny": "Annuler",
2607
+ "back": "Retour à l'application",
2608
+ "noWorkspaces": "Vous n'avez encore aucun tableau auquel le connecter.",
2609
+ "revokeHint": "Cela émet une clé d'API que vous pouvez révoquer à tout moment depuis les réglages du tableau.",
2610
+ "error": {
2611
+ "title": "Cette connexion n'a pas pu être établie",
2612
+ "noRequest": "Cette page a été ouverte sans demande d'autorisation. Lancez la connexion depuis votre hôte MCP.",
2613
+ "expired": "Cette demande d'autorisation est invalide ou a expiré. Relancez la connexion depuis votre hôte MCP.",
2614
+ "failed": "La décision n'a pas pu être enregistrée. Relancez la connexion depuis votre hôte MCP."
2615
+ }
2616
+ },
2578
2617
  "settings": {
2579
2618
  "modelConfiguration": {
2580
2619
  "title": "Configuration des modèles",
@@ -2575,6 +2575,45 @@
2575
2575
  }
2576
2576
  }
2577
2577
  },
2578
+ "mcpAuthorize": {
2579
+ "title": "לחבר את {client}?",
2580
+ "subtitle": "הוא מציג את עצמו כ־{client}, והפריסה הזו תחזיר אותו אל {origin}.",
2581
+ "workspace": {
2582
+ "label": "הלוח שבו יורשה לפעול"
2583
+ },
2584
+ "scopeLabel": "מה יורשה לעשות",
2585
+ "scopeHint": "כל רמה כוללת את הרמות שמעליה.",
2586
+ "scope": {
2587
+ "read": {
2588
+ "label": "קריאה בלבד",
2589
+ "description": "לראות שירותים, משימות, פייפליינים והרצות."
2590
+ },
2591
+ "write": {
2592
+ "label": "קריאה וכתיבה",
2593
+ "description": "וגם ליצור משימות ולהפעיל אותן."
2594
+ },
2595
+ "decide": {
2596
+ "label": "קריאה, כתיבה והכרעה",
2597
+ "description": "וגם לענות על השאלות שהרצה ממתינה להן."
2598
+ },
2599
+ "admin": {
2600
+ "label": "גישה מלאה",
2601
+ "description": "וגם למחוק משימות ולפעול על התראות, מה שעשוי למזג בקשת משיכה."
2602
+ }
2603
+ },
2604
+ "requestedScope": "{client} ביקש {scope}. בחרו באפשרות הזו למעלה רק אם אתם באמת מתכוונים להעניק אותה.",
2605
+ "approve": "חיבור",
2606
+ "deny": "ביטול",
2607
+ "back": "חזרה לאפליקציה",
2608
+ "noWorkspaces": "אין לך עדיין לוח שאפשר לחבר אליו.",
2609
+ "revokeHint": "פעולה זו מנפיקה מפתח API שאפשר לבטל בכל עת מהגדרות הלוח.",
2610
+ "error": {
2611
+ "title": "לא ניתן היה להקים את החיבור",
2612
+ "noRequest": "הדף נפתח ללא בקשת הרשאה. התחילו את החיבור מתוך מארח ה־MCP שלכם.",
2613
+ "expired": "בקשת ההרשאה אינה תקפה או שפג תוקפה. התחילו את החיבור שוב מתוך מארח ה־MCP שלכם.",
2614
+ "failed": "לא ניתן היה לרשום את ההחלטה. התחילו את החיבור שוב מתוך מארח ה־MCP שלכם."
2615
+ }
2616
+ },
2578
2617
  "settings": {
2579
2618
  "modelConfiguration": {
2580
2619
  "title": "הגדרת מודלים",
@@ -1,4 +1,43 @@
1
1
  {
2
+ "mcpAuthorize": {
3
+ "title": "Collegare {client}?",
4
+ "subtitle": "Dichiara di essere {client}, e questa installazione lo rimanderà a {origin}.",
5
+ "workspace": {
6
+ "label": "Bacheca su cui potrà agire"
7
+ },
8
+ "scopeLabel": "Cosa potrà fare",
9
+ "scopeHint": "Ogni livello include quelli precedenti.",
10
+ "scope": {
11
+ "read": {
12
+ "label": "Sola lettura",
13
+ "description": "Vedere servizi, attività, pipeline ed esecuzioni."
14
+ },
15
+ "write": {
16
+ "label": "Lettura e scrittura",
17
+ "description": "Inoltre creare e avviare attività."
18
+ },
19
+ "decide": {
20
+ "label": "Lettura, scrittura e decisione",
21
+ "description": "Inoltre rispondere alle domande su cui un'esecuzione è in attesa."
22
+ },
23
+ "admin": {
24
+ "label": "Accesso completo",
25
+ "description": "Inoltre eliminare attività e agire sulle notifiche, il che può unire una pull request."
26
+ }
27
+ },
28
+ "requestedScope": "{client} ha richiesto {scope}. Seleziona quell'opzione qui sopra solo se intendi davvero concederla.",
29
+ "approve": "Collega",
30
+ "deny": "Annulla",
31
+ "back": "Torna all'app",
32
+ "noWorkspaces": "Non hai ancora una bacheca a cui collegarlo.",
33
+ "revokeHint": "Questo emette una chiave API che puoi revocare in qualsiasi momento dalle impostazioni della bacheca.",
34
+ "error": {
35
+ "title": "Non è stato possibile creare questo collegamento",
36
+ "noRequest": "Questa pagina è stata aperta senza una richiesta di autorizzazione. Avvia il collegamento dal tuo host MCP.",
37
+ "expired": "Questa richiesta di autorizzazione non è valida o è scaduta. Riavvia il collegamento dal tuo host MCP.",
38
+ "failed": "Non è stato possibile registrare la decisione. Riavvia il collegamento dal tuo host MCP."
39
+ }
40
+ },
2
41
  "settings": {
3
42
  "modelConfiguration": {
4
43
  "title": "Configurazione del modello",
@@ -2575,6 +2575,45 @@
2575
2575
  }
2576
2576
  }
2577
2577
  },
2578
+ "mcpAuthorize": {
2579
+ "title": "{client} を接続しますか?",
2580
+ "subtitle": "{client} を名乗っており、このデプロイは接続後に {origin} へ戻します。",
2581
+ "workspace": {
2582
+ "label": "操作を許可するボード"
2583
+ },
2584
+ "scopeLabel": "許可する操作",
2585
+ "scopeHint": "各レベルは上位のレベルを含みます。",
2586
+ "scope": {
2587
+ "read": {
2588
+ "label": "読み取りのみ",
2589
+ "description": "サービス、タスク、パイプライン、実行を閲覧します。"
2590
+ },
2591
+ "write": {
2592
+ "label": "読み取りと書き込み",
2593
+ "description": "加えて、タスクの作成と開始ができます。"
2594
+ },
2595
+ "decide": {
2596
+ "label": "読み取り、書き込み、判断",
2597
+ "description": "加えて、停止中の実行が待っている質問に回答できます。"
2598
+ },
2599
+ "admin": {
2600
+ "label": "フルアクセス",
2601
+ "description": "加えて、タスクの削除と通知への対応ができ、プルリクエストがマージされることもあります。"
2602
+ }
2603
+ },
2604
+ "requestedScope": "{client} は {scope} を要求しました。本当に許可する場合のみ、上でその項目を選んでください。",
2605
+ "approve": "接続",
2606
+ "deny": "キャンセル",
2607
+ "back": "アプリに戻る",
2608
+ "noWorkspaces": "接続できるボードがまだありません。",
2609
+ "revokeHint": "これにより API キーが発行されます。ボードの設定からいつでも無効化できます。",
2610
+ "error": {
2611
+ "title": "この接続を設定できませんでした",
2612
+ "noRequest": "認可リクエストなしでこのページが開かれました。MCP ホストから接続を開始してください。",
2613
+ "expired": "この認可リクエストは無効か、有効期限が切れています。MCP ホストから接続をやり直してください。",
2614
+ "failed": "判断を記録できませんでした。MCP ホストから接続をやり直してください。"
2615
+ }
2616
+ },
2578
2617
  "settings": {
2579
2618
  "modelConfiguration": {
2580
2619
  "title": "モデル設定",
@@ -2575,6 +2575,45 @@
2575
2575
  }
2576
2576
  }
2577
2577
  },
2578
+ "mcpAuthorize": {
2579
+ "title": "Połączyć {client}?",
2580
+ "subtitle": "Podaje się za {client}, a ta instalacja odeśle go do {origin}.",
2581
+ "workspace": {
2582
+ "label": "Tablica, na której będzie działać"
2583
+ },
2584
+ "scopeLabel": "Co będzie mógł robić",
2585
+ "scopeHint": "Każdy poziom obejmuje poprzednie.",
2586
+ "scope": {
2587
+ "read": {
2588
+ "label": "Tylko odczyt",
2589
+ "description": "Podgląd usług, zadań, potoków i uruchomień."
2590
+ },
2591
+ "write": {
2592
+ "label": "Odczyt i zapis",
2593
+ "description": "Dodatkowo tworzenie i uruchamianie zadań."
2594
+ },
2595
+ "decide": {
2596
+ "label": "Odczyt, zapis i decyzje",
2597
+ "description": "Dodatkowo odpowiadanie na pytania, na które czeka wstrzymane uruchomienie."
2598
+ },
2599
+ "admin": {
2600
+ "label": "Pełny dostęp",
2601
+ "description": "Dodatkowo usuwanie zadań i reagowanie na powiadomienia, co może scalić pull request."
2602
+ }
2603
+ },
2604
+ "requestedScope": "{client} poprosił o {scope}. Wybierz tę opcję powyżej tylko wtedy, gdy naprawdę chcesz jej udzielić.",
2605
+ "approve": "Połącz",
2606
+ "deny": "Anuluj",
2607
+ "back": "Powrót do aplikacji",
2608
+ "noWorkspaces": "Nie masz jeszcze tablicy, z którą można to połączyć.",
2609
+ "revokeHint": "Zostanie wydany klucz API, który w każdej chwili możesz unieważnić w ustawieniach tablicy.",
2610
+ "error": {
2611
+ "title": "Nie udało się skonfigurować tego połączenia",
2612
+ "noRequest": "Ta strona została otwarta bez żądania autoryzacji. Rozpocznij łączenie w swoim hoście MCP.",
2613
+ "expired": "To żądanie autoryzacji jest nieprawidłowe lub wygasło. Rozpocznij łączenie ponownie w swoim hoście MCP.",
2614
+ "failed": "Nie udało się zapisać decyzji. Rozpocznij łączenie ponownie w swoim hoście MCP."
2615
+ }
2616
+ },
2578
2617
  "settings": {
2579
2618
  "modelConfiguration": {
2580
2619
  "title": "Konfiguracja modeli",
@@ -2575,6 +2575,45 @@
2575
2575
  }
2576
2576
  }
2577
2577
  },
2578
+ "mcpAuthorize": {
2579
+ "title": "{client} bağlansın mı?",
2580
+ "subtitle": "Kendisini {client} olarak tanıtıyor ve bu kurulum onu {origin} adresine geri gönderecek.",
2581
+ "workspace": {
2582
+ "label": "Üzerinde işlem yapabileceği pano"
2583
+ },
2584
+ "scopeLabel": "Neler yapabilecek",
2585
+ "scopeHint": "Her düzey kendisinden öncekileri de kapsar.",
2586
+ "scope": {
2587
+ "read": {
2588
+ "label": "Yalnızca okuma",
2589
+ "description": "Servisleri, görevleri, hatları ve çalışmaları görüntüler."
2590
+ },
2591
+ "write": {
2592
+ "label": "Okuma ve yazma",
2593
+ "description": "Ayrıca görev oluşturur ve başlatır."
2594
+ },
2595
+ "decide": {
2596
+ "label": "Okuma, yazma ve karar",
2597
+ "description": "Ayrıca duraklatılmış bir çalışmanın beklediği soruları yanıtlar."
2598
+ },
2599
+ "admin": {
2600
+ "label": "Tam erişim",
2601
+ "description": "Ayrıca görev siler ve bildirimlere işlem uygular; bu bir pull request'i birleştirebilir."
2602
+ }
2603
+ },
2604
+ "requestedScope": "{client} {scope} istedi. Bunu gerçekten vermek istiyorsanız yukarıdan o seçeneği seçin.",
2605
+ "approve": "Bağla",
2606
+ "deny": "Vazgeç",
2607
+ "back": "Uygulamaya dön",
2608
+ "noWorkspaces": "Bunu bağlayabileceğiniz bir pano henüz yok.",
2609
+ "revokeHint": "Bu işlem, pano ayarlarından istediğiniz zaman iptal edebileceğiniz bir API anahtarı oluşturur.",
2610
+ "error": {
2611
+ "title": "Bu bağlantı kurulamadı",
2612
+ "noRequest": "Bu sayfa bir yetkilendirme isteği olmadan açıldı. Bağlantıyı MCP istemcinizden başlatın.",
2613
+ "expired": "Bu yetkilendirme isteği geçersiz veya süresi dolmuş. Bağlantıyı MCP istemcinizden yeniden başlatın.",
2614
+ "failed": "Karar kaydedilemedi. Bağlantıyı MCP istemcinizden yeniden başlatın."
2615
+ }
2616
+ },
2578
2617
  "settings": {
2579
2618
  "modelConfiguration": {
2580
2619
  "title": "Model Yapılandırması",
@@ -2575,6 +2575,45 @@
2575
2575
  }
2576
2576
  }
2577
2577
  },
2578
+ "mcpAuthorize": {
2579
+ "title": "Підключити {client}?",
2580
+ "subtitle": "Він представляється як {client}, і це розгортання поверне його на {origin}.",
2581
+ "workspace": {
2582
+ "label": "Дошка, на якій він зможе діяти"
2583
+ },
2584
+ "scopeLabel": "Що йому дозволено",
2585
+ "scopeHint": "Кожен рівень включає попередні.",
2586
+ "scope": {
2587
+ "read": {
2588
+ "label": "Лише читання",
2589
+ "description": "Переглядати сервіси, завдання, конвеєри та запуски."
2590
+ },
2591
+ "write": {
2592
+ "label": "Читання та запис",
2593
+ "description": "А також створювати й запускати завдання."
2594
+ },
2595
+ "decide": {
2596
+ "label": "Читання, запис і рішення",
2597
+ "description": "А також відповідати на питання, на які чекає призупинений запуск."
2598
+ },
2599
+ "admin": {
2600
+ "label": "Повний доступ",
2601
+ "description": "А також видаляти завдання й діяти за сповіщеннями, що може злити пулреквест."
2602
+ }
2603
+ },
2604
+ "requestedScope": "{client} запросив {scope}. Обирайте цей варіант вище, лише якщо ви справді хочете його надати.",
2605
+ "approve": "Підключити",
2606
+ "deny": "Скасувати",
2607
+ "back": "Повернутися до застосунку",
2608
+ "noWorkspaces": "У вас поки немає дошки, до якої це можна підключити.",
2609
+ "revokeHint": "Буде видано ключ API, який ви будь-коли можете відкликати в налаштуваннях дошки.",
2610
+ "error": {
2611
+ "title": "Не вдалося налаштувати це підключення",
2612
+ "noRequest": "Цю сторінку відкрито без запиту авторизації. Почніть підключення у своєму хості MCP.",
2613
+ "expired": "Цей запит авторизації недійсний або застарів. Почніть підключення знову у своєму хості MCP.",
2614
+ "failed": "Не вдалося зберегти рішення. Почніть підключення знову у своєму хості MCP."
2615
+ }
2616
+ },
2578
2617
  "settings": {
2579
2618
  "modelConfiguration": {
2580
2619
  "title": "Налаштування моделей",
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@cat-factory/app",
3
- "version": "0.260.1",
3
+ "version": "0.261.0",
4
4
  "description": "Reusable Nuxt layer for the Agent Architecture Board SPA (components, stores, composables, pages). Consume it from a thin deployment app via `extends: ['@cat-factory/app']` and point it at your backend with NUXT_PUBLIC_API_BASE. See deploy/frontend for an example.",
5
5
  "repository": {
6
6
  "type": "git",
@@ -40,7 +40,7 @@
40
40
  "valibot": "^1.4.2",
41
41
  "vue": "3.5.41",
42
42
  "wretch": "^3.0.9",
43
- "@cat-factory/contracts": "0.291.0"
43
+ "@cat-factory/contracts": "0.292.0"
44
44
  },
45
45
  "devDependencies": {
46
46
  "@toad-contracts/testing": "0.3.2",