@cat-factory/app 0.200.4 → 0.201.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.
@@ -4,8 +4,14 @@
4
4
  // catalog carries, so rendering the same fields here is what makes the surface answer "what will
5
5
  // the design actually see?".
6
6
  //
7
- // `omittedOperations` is rendered rather than dropped: the operation list is CAPPED, and a reader
8
- // who assumed it was complete would conclude the missing endpoints do not exist.
7
+ // `omittedOperations` is rendered rather than dropped: the operation list is CAPPED (and, for a
8
+ // contract module, read by a best-effort static extractor), and a reader who assumed it complete
9
+ // would conclude the missing endpoints do not exist.
10
+ //
11
+ // An EMPTY list is likewise never left to speak for itself: `operationsAreIndexable` tells apart a
12
+ // format this platform does not read from an interface that genuinely declares nothing, and the
13
+ // two need opposite reactions from whoever is looking.
14
+ import { operationsAreIndexable } from '@cat-factory/contracts'
9
15
  import type { ApiContractFormat, ApiContractSummary } from '~/types/domain'
10
16
 
11
17
  defineProps<{
@@ -25,6 +31,12 @@ const { t, n } = useI18n()
25
31
  <span v-if="c.operations.length" class="ms-1 font-mono text-slate-500">
26
32
  {{ c.operations.join(' · ') }}
27
33
  </span>
34
+ <span v-else-if="!operationsAreIndexable(c.format)" class="ms-1 text-slate-600">
35
+ {{ t('foundational.contracts.notIndexed') }}
36
+ </span>
37
+ <span v-else-if="!c.omittedOperations" class="ms-1 text-slate-600">
38
+ {{ t('foundational.contracts.noOperations') }}
39
+ </span>
28
40
  <span v-if="c.omittedOperations > 0" class="ms-1 text-amber-500/80">
29
41
  {{ t('foundational.contracts.omitted', { count: c.omittedOperations }) }}
30
42
  </span>
@@ -1,7 +1,8 @@
1
1
  <script setup lang="ts">
2
- // The MERGED catalog an Architect is actually handed for this board — account ⊕ workspace, the
3
- // workspace winning by id (backend/docs/adr/0031-foundational-services.md). Workspace scope only: an
4
- // account has no tier above it to merge with.
2
+ // The MERGED catalog an Architect is actually handed for this board — the deployment's
3
+ // code-registered `builtin` services account workspace, the later tier winning by id
4
+ // (backend/docs/adr/0031-foundational-services.md). Workspace scope only: a board is what runs
5
+ // agents, so this is the only place the whole merge is realised.
5
6
  //
6
7
  // This view is where the two board-level decisions live, and they are deliberately different
7
8
  // actions rather than one "remove":
@@ -24,6 +25,7 @@ const { t } = useI18n()
24
25
 
25
26
  // Exhaustive maps of literal `t(...)` keys, so a new tier/format fails the typed-key guard.
26
27
  const tierLabel = computed<Record<FoundationalServiceTier, string>>(() => ({
28
+ builtin: t('foundational.tier.builtin'),
27
29
  account: t('foundational.tier.account'),
28
30
  workspace: t('foundational.tier.workspace'),
29
31
  }))
@@ -34,6 +36,7 @@ const formatLabel = computed<Record<ApiContractFormat, string>>(() => ({
34
36
  }))
35
37
  // `as const` keeps the literal colour names assignable to UBadge's `color` union.
36
38
  const tierColor = {
39
+ builtin: 'neutral',
37
40
  account: 'info',
38
41
  workspace: 'primary',
39
42
  } as const satisfies Record<FoundationalServiceTier, string>
@@ -147,10 +150,11 @@ async function suppress(serviceId: string) {
147
150
  <UBadge size="xs" :color="tierColor[s.tier]" variant="subtle">
148
151
  {{ tierLabel[s.tier] }}
149
152
  </UBadge>
150
- <!-- Only an INHERITED entry can be suppressed; the board's own row is managed in the
151
- registry tab, where deleting it is the honest action. -->
153
+ <!-- Only an INHERITED entry can be suppressed an account service or one the
154
+ deployment registered in code; the board's own row is managed in the registry tab,
155
+ where deleting it is the honest action. -->
152
156
  <UButton
153
- v-if="s.tier === 'account'"
157
+ v-if="s.tier !== 'workspace'"
154
158
  icon="i-lucide-eye-off"
155
159
  size="xs"
156
160
  variant="ghost"
@@ -7,8 +7,9 @@
7
7
  //
8
8
  // The tab split is the feature's own split, not a layout choice: "Catalog" is what an agent sees
9
9
  // (identity + operation names, never a document body), while "This tier" is what this owner
10
- // actually registers. An account has no tier above it, so it gets no catalog tab and no
11
- // suppression controls.
10
+ // actually registers. Only a board gets the catalog tab it is what runs agents but BOTH
11
+ // scopes get the suppression list, because an account inherits the deployment's code-registered
12
+ // services exactly as a board inherits its account's.
12
13
  import { computed, ref, watch } from 'vue'
13
14
  import type { FoundationalServiceOwnerKind } from '~/types/domain'
14
15
  import {
@@ -97,11 +98,20 @@ const activeTab = computed({
97
98
  <template #catalog>
98
99
  <div class="flex flex-col gap-4">
99
100
  <FoundationalServiceCatalogList />
100
- <FoundationalSuppressions />
101
+ <FoundationalSuppressions :kind="props.kind" :owner-id="props.ownerId" />
101
102
  </div>
102
103
  </template>
103
104
  <template #registry>
104
- <FoundationalServiceRegistry :kind="props.kind" :owner-id="props.ownerId" />
105
+ <div class="flex flex-col gap-4">
106
+ <FoundationalServiceRegistry :kind="props.kind" :owner-id="props.ownerId" />
107
+ <!-- A scope with no catalog tab still has to be able to see (and lift) what it is
108
+ opting out of; with one, the list lives beside the catalog it explains. -->
109
+ <FoundationalSuppressions
110
+ v-if="!props.showCatalog"
111
+ :kind="props.kind"
112
+ :owner-id="props.ownerId"
113
+ />
114
+ </div>
105
115
  </template>
106
116
  <template #sources>
107
117
  <FoundationalServiceSources :kind="props.kind" :owner-id="props.ownerId" />
@@ -1,19 +1,43 @@
1
1
  <script setup lang="ts">
2
- // What this board is opted OUT of (backend/docs/adr/0031-foundational-services.md). A suppressed id is
2
+ // What this tier is opted OUT of (backend/docs/adr/0031-foundational-services.md). A suppressed id is
3
3
  // by construction absent from the merged catalog, so without this list the suppress action would
4
4
  // be a one-way door — which is why it is a section of its own rather than a badge on the catalog.
5
5
  //
6
+ // Rendered at BOTH scopes, because both inherit: a board from its account, and either from the
7
+ // services the deployment registered in code (the `builtin` tier).
8
+ //
6
9
  // `inherited: false` is rendered distinctly rather than hidden: the tombstone shadows nothing
7
- // today (the account withdrew the service, or this row is what remains of the board deleting its
8
- // own registration), and an operator reading it as "a capability is being withheld" would go
10
+ // today (the tier below withdrew the service, or this row is what remains of this tier deleting
11
+ // its own registration), and an operator reading it as "a capability is being withheld" would go
9
12
  // looking for something that is not there.
10
- import { reactive } from 'vue'
11
- import { useFoundationalServicesStore } from '~/stores/foundationalServices'
13
+ import { computed, reactive } from 'vue'
14
+ import type { FoundationalServiceOwnerKind } from '~/types/domain'
15
+ import {
16
+ useFoundationalServices,
17
+ useFoundationalServicesStore,
18
+ } from '~/stores/foundationalServices'
19
+
20
+ const props = defineProps<{ kind: FoundationalServiceOwnerKind; ownerId: string }>()
12
21
 
13
- const catalog = useFoundationalServicesStore()
22
+ const catalog =
23
+ props.kind === 'workspace'
24
+ ? useFoundationalServicesStore()
25
+ : useFoundationalServices(props.kind, props.ownerId)
14
26
  const toast = useToast()
15
27
  const { t } = useI18n()
16
28
 
29
+ // Exhaustive scope→key maps of literal `t(...)` keys: what a board opts out of (its account's
30
+ // services) and what an account opts out of (the deployment's) are different enough sentences
31
+ // that one string with a placeholder would read as neither.
32
+ const title = computed<Record<FoundationalServiceOwnerKind, string>>(() => ({
33
+ workspace: t('foundational.suppressions.title.workspace'),
34
+ account: t('foundational.suppressions.title.account'),
35
+ }))
36
+ const intro = computed<Record<FoundationalServiceOwnerKind, string>>(() => ({
37
+ workspace: t('foundational.suppressions.intro.workspace'),
38
+ account: t('foundational.suppressions.intro.account'),
39
+ }))
40
+
17
41
  const busyRows = reactive(new Set<string>())
18
42
  const rowBusy = (id: string) => busyRows.has(id)
19
43
 
@@ -42,8 +66,8 @@ async function restore(serviceId: string) {
42
66
  class="flex flex-col gap-2"
43
67
  data-testid="foundational-suppressions"
44
68
  >
45
- <p class="text-sm font-medium">{{ t('foundational.suppressions.title') }}</p>
46
- <p class="text-xs text-slate-500">{{ t('foundational.suppressions.intro') }}</p>
69
+ <p class="text-sm font-medium">{{ title[props.kind] }}</p>
70
+ <p class="text-xs text-slate-500">{{ intro[props.kind] }}</p>
47
71
  <div
48
72
  v-for="s in catalog.suppressions"
49
73
  :key="s.id"
@@ -0,0 +1,202 @@
1
+ <script setup lang="ts">
2
+ // The branch-protection preflight: for each linked repository, is its DEFAULT branch protected
3
+ // on the host?
4
+ //
5
+ // This is the one control the platform can neither provide nor enforce. An agent run pushes with
6
+ // a `Contents: write` credential, and nothing platform-side stops a stolen one from pushing
7
+ // straight to `main` — or merging an open pull request through the host's merge API, which needs
8
+ // no more permission than the push already had. Branch protection is what covers both, it lives
9
+ // on GitHub rather than in this product, and until now nothing in-product said when it was
10
+ // missing. See `backend/docs/security-model.md`.
11
+ //
12
+ // On demand, never on open: each repository costs one or two live GitHub reads, so a panel that
13
+ // probed automatically would spend an operator's rate limit every time they glanced at settings.
14
+ import { computed, ref } from 'vue'
15
+ import type { BranchProtectionReportView, BranchProtectionStateValue } from '@cat-factory/contracts'
16
+ import { describeGenericFailure } from '~/composables/usePipelineErrorToast'
17
+
18
+ const { t } = useI18n()
19
+ const api = useApi()
20
+ const workspace = useWorkspaceStore()
21
+
22
+ const report = ref<BranchProtectionReportView | null>(null)
23
+ const checking = ref(false)
24
+ // A failed probe is described from its STATUS CLASS in translated copy, with the backend's
25
+ // untranslated prose (plus the request id an operator greps for) behind a disclosure — the same
26
+ // split every other failure surface makes, through the same pure classifier the run-control
27
+ // toasts use. Rendering `e.message` as the headline would hand a non-English operator English
28
+ // on the one panel whose entire job is telling them their deployment is exposed.
29
+ const failure = ref<{ descriptionKey: string; detail: string } | null>(null)
30
+ const showFailureDetail = ref(false)
31
+
32
+ type Row = BranchProtectionReportView['repos'][number]
33
+ type UnknownReason = NonNullable<Row['protection']['reason']>
34
+
35
+ /** Unprotected first, then unknown, then protected — the order an operator has to act in. */
36
+ const RANK: Record<BranchProtectionStateValue, number> = {
37
+ unprotected: 0,
38
+ unknown: 1,
39
+ protected: 2,
40
+ }
41
+ const rows = computed(() =>
42
+ [...(report.value?.repos ?? [])].sort(
43
+ (a, b) => RANK[a.protection.state] - RANK[b.protection.state],
44
+ ),
45
+ )
46
+ const exposedCount = computed(
47
+ () => rows.value.filter((r) => r.protection.state === 'unprotected').length,
48
+ )
49
+
50
+ const STATE_STYLE: Record<BranchProtectionStateValue, string> = {
51
+ unprotected: 'text-rose-300',
52
+ unknown: 'text-amber-300',
53
+ protected: 'text-emerald-400',
54
+ }
55
+
56
+ // Exhaustive Records of LITERAL keys, not an assembled `\`…\${state}\`` lookup: the typed-key
57
+ // check cannot see a runtime-assembled key, so these are what fail the build when the backend
58
+ // adds a state or a reason the SPA has no copy for.
59
+ const STATE_LABEL: Record<BranchProtectionStateValue, () => string> = {
60
+ protected: () => t('vcs.branchProtection.state.protected'),
61
+ unprotected: () => t('vcs.branchProtection.state.unprotected'),
62
+ unknown: () => t('vcs.branchProtection.state.unknown'),
63
+ }
64
+ const UNKNOWN_REASON: Record<UnknownReason, () => string> = {
65
+ branch_not_found: () => t('vcs.branchProtection.unknownReason.branch_not_found'),
66
+ forbidden: () => t('vcs.branchProtection.unknownReason.forbidden'),
67
+ error: () => t('vcs.branchProtection.unknownReason.error'),
68
+ }
69
+
70
+ /**
71
+ * The line under a repository. `unknown` names its CAUSE, and a protected branch whose rule we
72
+ * could not read says so rather than passing as fully verified — the two are different operator
73
+ * situations, and collapsing them is how a report starts lying.
74
+ */
75
+ function detailLine(row: Row): string {
76
+ const p = row.protection
77
+ if (p.state === 'unknown') return UNKNOWN_REASON[p.reason ?? 'error']()
78
+ if (p.state === 'unprotected') return t('vcs.branchProtection.unprotectedHint')
79
+ if (p.detailUnavailable) return t('vcs.branchProtection.detailUnavailable')
80
+ if (!p.detail) return ''
81
+ return p.detail.requiresPullRequest
82
+ ? t('vcs.branchProtection.requiresPr', {
83
+ reviews: p.detail.requiredApprovingReviewCount,
84
+ checks: p.detail.requiredStatusChecks.length,
85
+ })
86
+ : t('vcs.branchProtection.noPrRequired')
87
+ }
88
+
89
+ async function check() {
90
+ const workspaceId = workspace.workspaceId
91
+ if (!workspaceId) return
92
+ checking.value = true
93
+ failure.value = null
94
+ showFailureDetail.value = false
95
+ try {
96
+ report.value = await api.checkGitHubBranchProtection(workspaceId)
97
+ } catch (e) {
98
+ const described = describeGenericFailure(e)
99
+ // Joined exactly as the toast disclosure joins it, so the same failure reads the same way
100
+ // wherever it surfaces. Empty parts drop out: a disclosure that reveals nothing is worse
101
+ // than no disclosure, so the button is hidden when there is no detail to show.
102
+ const detail = [
103
+ described.message,
104
+ described.issues.join(', '),
105
+ described.requestId ? t('errors.generic.requestId', { id: described.requestId }) : '',
106
+ ]
107
+ .filter((part) => part && part.trim().length > 0)
108
+ .join(' · ')
109
+ failure.value = { descriptionKey: described.descriptionKey, detail }
110
+ } finally {
111
+ checking.value = false
112
+ }
113
+ }
114
+ </script>
115
+
116
+ <template>
117
+ <section class="space-y-2" data-testid="branch-protection-preflight">
118
+ <h3 class="text-sm font-semibold text-slate-200">{{ t('vcs.branchProtection.heading') }}</h3>
119
+ <p class="text-[11px] text-slate-400">{{ t('vcs.branchProtection.body') }}</p>
120
+
121
+ <UButton
122
+ color="neutral"
123
+ variant="soft"
124
+ size="xs"
125
+ icon="i-lucide-shield-check"
126
+ :loading="checking"
127
+ data-testid="branch-protection-check"
128
+ @click="check()"
129
+ >
130
+ {{ t('vcs.branchProtection.check') }}
131
+ </UButton>
132
+
133
+ <div v-if="failure" class="space-y-1" data-testid="branch-protection-failure">
134
+ <p class="text-xs text-rose-400">{{ t(failure.descriptionKey) }}</p>
135
+ <!-- The raw prose stays reachable, never dropped: the backend's messages here name the
136
+ operator remedy, and the request id is the join to the one server log line that
137
+ explains it. One click away, so it is never what a user is shown FIRST. -->
138
+ <UButton
139
+ v-if="failure.detail && !showFailureDetail"
140
+ color="neutral"
141
+ variant="link"
142
+ size="xs"
143
+ class="p-0"
144
+ data-testid="branch-protection-failure-detail"
145
+ @click="showFailureDetail = true"
146
+ >
147
+ {{ t('errors.generic.showDetail') }}
148
+ </UButton>
149
+ <p v-if="showFailureDetail" class="font-mono text-[10px] break-all text-slate-500">
150
+ {{ failure.detail }}
151
+ </p>
152
+ </div>
153
+
154
+ <!-- The provider cannot answer this at all. Said explicitly, because an empty list here
155
+ would otherwise read exactly like a clean bill of health. -->
156
+ <p
157
+ v-else-if="report && report.capability === 'unavailable'"
158
+ class="text-xs text-amber-300"
159
+ data-testid="branch-protection-unavailable"
160
+ >
161
+ {{ t('vcs.branchProtection.unavailable') }}
162
+ </p>
163
+
164
+ <template v-else-if="report">
165
+ <p
166
+ class="text-xs"
167
+ :class="exposedCount ? 'text-rose-300' : 'text-emerald-400'"
168
+ data-testid="branch-protection-summary"
169
+ >
170
+ {{
171
+ exposedCount
172
+ ? t('vcs.branchProtection.exposed', { count: exposedCount }, exposedCount)
173
+ : t('vcs.branchProtection.allProtected')
174
+ }}
175
+ </p>
176
+
177
+ <ul class="space-y-1">
178
+ <li
179
+ v-for="row in rows"
180
+ :key="row.repoGithubId"
181
+ class="rounded-md border border-slate-800 bg-slate-900/40 px-2 py-1.5"
182
+ >
183
+ <div class="flex items-baseline justify-between gap-2">
184
+ <span class="font-mono text-xs text-slate-300">{{ row.owner }}/{{ row.name }}</span>
185
+ <span class="text-[11px]" :class="STATE_STYLE[row.protection.state]">
186
+ {{ STATE_LABEL[row.protection.state]() }}
187
+ </span>
188
+ </div>
189
+ <p class="text-[10px] text-slate-500">
190
+ {{ row.defaultBranch }}<span v-if="detailLine(row)"> — {{ detailLine(row) }}</span>
191
+ </p>
192
+ </li>
193
+ </ul>
194
+
195
+ <!-- A cap that truncated silently would read as "these are all your repositories", which
196
+ on a security report is the same failure as calling an unprobed repo protected. -->
197
+ <p v-if="report.omittedRepos > 0" class="text-[11px] text-amber-300">
198
+ {{ t('vcs.branchProtection.omitted', { count: report.omittedRepos }, report.omittedRepos) }}
199
+ </p>
200
+ </template>
201
+ </section>
202
+ </template>
@@ -10,12 +10,14 @@ import type { GitHubPullRequest, GitHubRepo, VcsProvider } from '~/types/domain'
10
10
  // tag, so it silently renders as an empty element. Importing it directly binds
11
11
  // the tag unambiguously.
12
12
  import GitHubConnect from './GitHubConnect.vue'
13
+ import BranchProtectionPreflight from './BranchProtectionPreflight.vue'
13
14
  import GitLabConnect from '~/components/vcs/GitLabConnect.vue'
14
15
  import IntegrationBackTitle from '~/components/layout/IntegrationBackTitle.vue'
15
16
  import { VCS_PROVIDER_ICONS, VCS_PROVIDER_LABELS } from '~/utils/vcs'
16
17
 
17
18
  const { t } = useI18n()
18
19
  const ui = useUiStore()
20
+ const access = useWorkspaceAccess()
19
21
  const github = useGitHubStore()
20
22
  const toast = useToast()
21
23
  const { confirm } = useConfirm()
@@ -459,6 +461,18 @@ async function merge(pr: GitHubPullRequest) {
459
461
  <p v-if="!github.repos.length && !managing" class="py-4 text-sm text-slate-400">
460
462
  {{ t('github.panel.noLinkedRepos') }}
461
463
  </p>
464
+
465
+ <!-- Whether the host actually protects what an agent run pushes to. Placed above the
466
+ repo list because it is a posture check across ALL of them, not a per-repo
467
+ attribute. Admin-only to match the route: the probe spends the installation's
468
+ GitHub rate limit, which the CI gate and the merger share, so it is gated rather
469
+ than merely read-only. Hidden rather than disabled — a member has nothing to do
470
+ with a control whose result is the operator's to act on. -->
471
+ <BranchProtectionPreflight
472
+ v-if="github.repos.length && access.canManageIntegrations.value"
473
+ class="rounded-md border border-slate-800 bg-slate-900/40 p-3"
474
+ />
475
+
462
476
  <div
463
477
  v-for="repo in github.repos"
464
478
  :key="repo.githubId"
@@ -0,0 +1,97 @@
1
+ <script setup lang="ts">
2
+ import { onMounted, ref, watch } from 'vue'
3
+
4
+ // The account-wide FLOOR under each board's "may a run act as its initiator's own personal
5
+ // access token?" switch (admin only).
6
+ //
7
+ // Why a second tier at all: the workspace switch is edited with `settings.manage`, which a
8
+ // member elevated on one board holds — and the control exists precisely for the case where
9
+ // someone re-widens what the operator scoped. A floor a board admin cannot lift is the only
10
+ // version of it that binds. Effective = this permits AND the board permits.
11
+ //
12
+ // Left UNSET by default, deliberately. A personal token is the right credential for someone
13
+ // adopting cat-factory alone inside an org that has not adopted it: there is no App
14
+ // installation to inherit and no account admin to ask. Setting this is for the opposite case.
15
+ // See `backend/docs/security-model.md`.
16
+ const props = defineProps<{ accountId: string }>()
17
+
18
+ const store = useAccountSettingsStore()
19
+ const toast = useToast()
20
+ const { t } = useI18n()
21
+
22
+ // Tri-state, and it must stay tri-state: "not set" is a real, distinct answer from "permitted".
23
+ // Collapsing it to a boolean would make every existing account look as though an admin had
24
+ // actively decided, and would write that decision on the next unrelated save.
25
+ const forbid = ref(false)
26
+ const saving = ref(false)
27
+
28
+ function hydrate() {
29
+ forbid.value = store.view?.config?.allowInitiatorPat === false
30
+ }
31
+
32
+ onMounted(async () => {
33
+ if (!store.view && store.available !== false) await store.load(props.accountId)
34
+ hydrate()
35
+ })
36
+ watch(() => store.view, hydrate)
37
+
38
+ async function save() {
39
+ saving.value = true
40
+ try {
41
+ // `config` fully REPLACES the stored non-secret config, so the rest is carried forward —
42
+ // the same contract the model-policy editor honours. Clearing the floor writes `undefined`
43
+ // rather than `true`, keeping "no opinion" distinguishable from "explicitly permitted".
44
+ await store.save(props.accountId, {
45
+ config: {
46
+ ...store.view?.config,
47
+ ...(forbid.value ? { allowInitiatorPat: false } : { allowInitiatorPat: undefined }),
48
+ },
49
+ })
50
+ toast.add({ title: t('settings.runCredentialPolicy.saved'), color: 'success' })
51
+ } catch (e) {
52
+ toast.add({
53
+ title: t('settings.runCredentialPolicy.saveFailed'),
54
+ description: e instanceof Error ? e.message : String(e),
55
+ color: 'error',
56
+ })
57
+ } finally {
58
+ saving.value = false
59
+ }
60
+ }
61
+ </script>
62
+
63
+ <template>
64
+ <div v-if="store.available !== false" class="space-y-2" data-testid="account-run-credential">
65
+ <h3 class="text-sm font-semibold text-slate-200">
66
+ {{ t('settings.runCredentialPolicy.heading') }}
67
+ </h3>
68
+ <p class="text-[11px] text-slate-400">{{ t('settings.runCredentialPolicy.body') }}</p>
69
+
70
+ <label class="flex items-center gap-2">
71
+ <USwitch v-model="forbid" size="sm" data-testid="account-forbid-initiator-pat" />
72
+ <span class="text-sm text-slate-200">{{ t('settings.runCredentialPolicy.toggle') }}</span>
73
+ </label>
74
+
75
+ <!-- What the choice actually costs, stated at the point of choosing rather than discovered
76
+ later: attribution is a real feature, and turning this on is what trades it away. -->
77
+ <p v-if="forbid" class="text-[11px] text-amber-300">
78
+ {{ t('settings.runCredentialPolicy.onHint') }}
79
+ </p>
80
+ <p v-else class="text-[11px] text-slate-500">
81
+ {{ t('settings.runCredentialPolicy.offHint') }}
82
+ </p>
83
+
84
+ <div class="flex justify-end">
85
+ <UButton
86
+ color="primary"
87
+ size="xs"
88
+ icon="i-lucide-save"
89
+ :loading="saving"
90
+ data-testid="account-run-credential-save"
91
+ @click="save"
92
+ >
93
+ {{ t('common.save') }}
94
+ </UButton>
95
+ </div>
96
+ </div>
97
+ </template>
@@ -5,6 +5,7 @@ import type { AccountRole } from '~/types/domain'
5
5
  import type { InvitationStatus } from '@cat-factory/contracts'
6
6
  import AccountDeploymentSettings from '~/components/layout/AccountDeploymentSettings.vue'
7
7
  import AccountModelPolicySettings from '~/components/layout/AccountModelPolicySettings.vue'
8
+ import AccountRunCredentialSettings from '~/components/layout/AccountRunCredentialSettings.vue'
8
9
  import SecretInput from '~/components/common/SecretInput.vue'
9
10
 
10
11
  // Team settings for an org account: the member roster (with combinable admin /
@@ -337,5 +338,13 @@ async function disconnectEmail() {
337
338
  <section v-if="isAdmin && modelPolicySupported">
338
339
  <AccountModelPolicySettings :account-id="accountId" />
339
340
  </section>
341
+
342
+ <!-- account-wide floor under each board's run-credential switch (admin-only). Not gated on
343
+ `modelPolicySupported`: unlike a model policy this binds wherever account settings
344
+ exist, and a deployment that could not enforce it would be the one case where saying
345
+ so matters most. -->
346
+ <section v-if="isAdmin">
347
+ <AccountRunCredentialSettings :account-id="accountId" />
348
+ </section>
340
349
  </div>
341
350
  </template>
@@ -14,7 +14,15 @@
14
14
  import type { ConnectionWarning } from '@cat-factory/contracts'
15
15
  import { CONNECTION_WARNING_KEYS } from '~/utils/connectionWarnings'
16
16
 
17
- defineProps<{ warnings?: ConnectionWarning[] }>()
17
+ const props = defineProps<{
18
+ warnings?: ConnectionWarning[]
19
+ /**
20
+ * Heading for the block. Defaults to the connection-test wording ("gaps in this
21
+ * configuration"), which is wrong for a surface where the finding is about a CREDENTIAL's
22
+ * reach rather than a config gap — those callers pass their own already-translated title.
23
+ */
24
+ title?: string
25
+ }>()
18
26
 
19
27
  const { t, te } = useI18n()
20
28
 
@@ -30,7 +38,9 @@ const copy = (warning: ConnectionWarning): string => {
30
38
  class="rounded-md border border-amber-500/40 bg-amber-950/40 px-3 py-2 text-xs text-amber-200"
31
39
  data-testid="connection-warnings"
32
40
  >
33
- <p class="font-semibold">{{ t('settings.providerConnection.test.warningsTitle') }}</p>
41
+ <p class="font-semibold">
42
+ {{ props.title ?? t('settings.providerConnection.test.warningsTitle') }}
43
+ </p>
34
44
  <ul class="mt-1 list-disc space-y-1 pl-4">
35
45
  <li v-for="warning in warnings" :key="warning.code">{{ copy(warning) }}</li>
36
46
  </ul>
@@ -5,9 +5,11 @@
5
5
  // renders them without hard-coding any kind. Stored PER USER (runs you initiate use YOUR
6
6
  // access); the secret is write-only server-side and never shown again.
7
7
  import { computed, ref, watch } from 'vue'
8
+ import type { ConnectionTestResult } from '@cat-factory/contracts'
8
9
  import type { ProviderConfigField, UserSecretKind } from '~/types/userSecrets'
9
10
  import IntegrationBackTitle from '~/components/layout/IntegrationBackTitle.vue'
10
11
  import SecretInput from '~/components/common/SecretInput.vue'
12
+ import ConnectionWarnings from '~/components/settings/ConnectionWarnings.vue'
11
13
 
12
14
  const { t } = useI18n()
13
15
  const ui = useUiStore()
@@ -30,7 +32,7 @@ const status = computed(() => store.statusFor(kind.value))
30
32
  // all other fields map into `metadata`.
31
33
  const values = ref<Record<string, string>>({})
32
34
  const labelDraft = ref('')
33
- const testResult = ref<{ ok: boolean; message?: string } | null>(null)
35
+ const testResult = ref<ConnectionTestResult | null>(null)
34
36
  const testing = ref(false)
35
37
  const busy = ref(false)
36
38
 
@@ -103,7 +105,13 @@ async function save() {
103
105
  try {
104
106
  await store.store(kind.value, { ...payload, label: labelDraft.value.trim() || undefined })
105
107
  values.value[secretField.value!.key] = ''
106
- testResult.value = null
108
+ // Probe AFTER the save so what this credential can reach is stated at the moment it is
109
+ // stored, not only when someone happens to press Test first. A run you start authenticates
110
+ // with this token in preference to the deployment's own, and the platform cannot narrow it —
111
+ // so this is the one point at which an over-broad token is visible at all
112
+ // (backend/docs/security-model.md). Best-effort: the save already succeeded, so a probe
113
+ // failure must not read as one.
114
+ testResult.value = await store.test(kind.value, payload).catch(() => null)
107
115
  toast.add({
108
116
  title: t('settings.userSecrets.toast.saved', {
109
117
  label: descriptor.value?.label ?? t('settings.userSecrets.secretFallback'),
@@ -236,6 +244,13 @@ async function remove() {
236
244
  </span>
237
245
  </div>
238
246
 
247
+ <!-- How far this token reaches. Independent of the pass/fail verdict: an over-broad
248
+ token is perfectly VALID, and that is exactly why it is otherwise invisible. -->
249
+ <ConnectionWarnings
250
+ :warnings="testResult?.warnings"
251
+ :title="t('settings.userSecrets.tokenReachTitle')"
252
+ />
253
+
239
254
  <div class="flex justify-end">
240
255
  <UButton
241
256
  color="primary"