@cat-factory/app 0.119.1 → 0.121.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.
@@ -102,6 +102,13 @@ const isRecurring = computed(() => taskType.value === 'recurring')
102
102
  const severity = ref<'low' | 'medium' | 'high' | 'critical' | ''>('')
103
103
  const stepsToReproduce = ref('')
104
104
  const timeboxHours = ref<number | undefined>(undefined)
105
+ // Spike research criteria — folded into the spike agent's prompt (see the backend `spike` kind).
106
+ const spikeResearchQuestion = ref('')
107
+ const spikeSuccessCriteria = ref('')
108
+ const spikeOptionsToCompare = ref('')
109
+ // Optional in-repo path the findings document is committed to (else `docs/research/<slug>.md`);
110
+ // shares the `taskTypeFields.targetPath` field + its safe-`.md`-path validation with `document`.
111
+ const spikeTargetPath = ref('')
105
112
  // `DOC_KINDS` (and the `DocKind` type) are owned by the contracts package — re-exported via
106
113
  // `~/types/domain` — so the picker and the create payload can't drift from the backend list.
107
114
  const docKind = ref<DocKind | ''>('')
@@ -171,13 +178,21 @@ function buildTypeFields(): TaskTypeFields | undefined {
171
178
  return Object.keys(f).length ? f : undefined
172
179
  }
173
180
  if (taskType.value === 'spike') {
181
+ const f: TaskTypeFields = {}
174
182
  // `v-model.number` on a cleared number input yields '' (not undefined), which would
175
183
  // serialise as a non-number and 400 the create — so require a finite number here.
176
- return typeof timeboxHours.value === 'number' &&
184
+ if (
185
+ typeof timeboxHours.value === 'number' &&
177
186
  Number.isFinite(timeboxHours.value) &&
178
187
  timeboxHours.value >= 0
179
- ? { timeboxHours: timeboxHours.value }
180
- : undefined
188
+ ) {
189
+ f.timeboxHours = timeboxHours.value
190
+ }
191
+ if (spikeResearchQuestion.value.trim()) f.researchQuestion = spikeResearchQuestion.value.trim()
192
+ if (spikeSuccessCriteria.value.trim()) f.successCriteria = spikeSuccessCriteria.value.trim()
193
+ if (spikeOptionsToCompare.value.trim()) f.optionsToCompare = spikeOptionsToCompare.value.trim()
194
+ if (spikeTargetPath.value.trim()) f.targetPath = spikeTargetPath.value.trim()
195
+ return Object.keys(f).length ? f : undefined
181
196
  }
182
197
  if (taskType.value === 'document') {
183
198
  const f: TaskTypeFields = {}
@@ -406,6 +421,10 @@ watch(open, (isOpen) => {
406
421
  severity.value = ''
407
422
  stepsToReproduce.value = ''
408
423
  timeboxHours.value = undefined
424
+ spikeResearchQuestion.value = ''
425
+ spikeSuccessCriteria.value = ''
426
+ spikeOptionsToCompare.value = ''
427
+ spikeTargetPath.value = ''
409
428
  docKind.value = ''
410
429
  docAudience.value = ''
411
430
  docTargetPath.value = ''
@@ -451,6 +470,10 @@ const { requestClose } = useUnsavedGuard({
451
470
  severity: severity.value,
452
471
  stepsToReproduce: stepsToReproduce.value.trim(),
453
472
  timeboxHours: timeboxHours.value ?? null,
473
+ spikeResearchQuestion: spikeResearchQuestion.value.trim(),
474
+ spikeSuccessCriteria: spikeSuccessCriteria.value.trim(),
475
+ spikeOptionsToCompare: spikeOptionsToCompare.value.trim(),
476
+ spikeTargetPath: spikeTargetPath.value.trim(),
454
477
  docKind: docKind.value,
455
478
  docAudience: docAudience.value.trim(),
456
479
  docTargetPath: docTargetPath.value.trim(),
@@ -671,15 +694,58 @@ async function add() {
671
694
  </UFormField>
672
695
  </div>
673
696
 
674
- <UFormField v-else-if="taskType === 'spike'" :label="t('board.addTask.timebox')">
675
- <UInput
676
- v-model.number="timeboxHours"
677
- type="number"
678
- min="0"
679
- :placeholder="t('board.addTask.timeboxPlaceholder')"
680
- class="w-full"
681
- />
682
- </UFormField>
697
+ <div v-else-if="taskType === 'spike'" class="space-y-3">
698
+ <UFormField :label="t('board.addTask.timebox')">
699
+ <UInput
700
+ v-model.number="timeboxHours"
701
+ type="number"
702
+ min="0"
703
+ :placeholder="t('board.addTask.timeboxPlaceholder')"
704
+ class="w-full"
705
+ />
706
+ </UFormField>
707
+ <UFormField
708
+ :label="t('board.addTask.spikeFields.researchQuestion.label')"
709
+ :hint="t('board.addTask.optional')"
710
+ >
711
+ <UInput
712
+ v-model="spikeResearchQuestion"
713
+ :placeholder="t('board.addTask.spikeFields.researchQuestion.placeholder')"
714
+ class="w-full"
715
+ />
716
+ </UFormField>
717
+ <UFormField
718
+ :label="t('board.addTask.spikeFields.successCriteria.label')"
719
+ :hint="t('board.addTask.optional')"
720
+ >
721
+ <UTextarea
722
+ v-model="spikeSuccessCriteria"
723
+ :rows="2"
724
+ autoresize
725
+ :placeholder="t('board.addTask.spikeFields.successCriteria.placeholder')"
726
+ class="w-full"
727
+ />
728
+ </UFormField>
729
+ <UFormField
730
+ :label="t('board.addTask.spikeFields.optionsToCompare.label')"
731
+ :hint="t('board.addTask.optional')"
732
+ >
733
+ <UTextarea
734
+ v-model="spikeOptionsToCompare"
735
+ :rows="2"
736
+ autoresize
737
+ :placeholder="t('board.addTask.spikeFields.optionsToCompare.placeholder')"
738
+ class="w-full"
739
+ />
740
+ </UFormField>
741
+ <UFormField :label="t('board.addTask.targetPath')" :hint="t('board.addTask.optional')">
742
+ <UInput
743
+ v-model="spikeTargetPath"
744
+ :placeholder="t('board.addTask.targetPathPlaceholder')"
745
+ class="w-full"
746
+ />
747
+ </UFormField>
748
+ </div>
683
749
 
684
750
  <div v-else-if="taskType === 'document'" class="space-y-3">
685
751
  <UFormField :label="t('board.addTask.documentKind')">
@@ -21,6 +21,7 @@ const tasks = useTasksStore()
21
21
  const tracker = useTrackerStore()
22
22
  const releaseHealth = useReleaseHealthStore()
23
23
  const packageRegistries = usePackageRegistriesStore()
24
+ const publicApiKeys = usePublicApiKeysStore()
24
25
  const userSecrets = useUserSecretsStore()
25
26
  const apiKeys = useApiKeysStore()
26
27
  const workspace = useWorkspaceStore()
@@ -51,6 +52,7 @@ watch(
51
52
  query.value = ''
52
53
  void releaseHealth.ensureLoaded().catch(() => {})
53
54
  void packageRegistries.ensureLoaded().catch(() => {})
55
+ void publicApiKeys.ensureLoaded().catch(() => {})
54
56
  void userSecrets.load().catch(() => {})
55
57
  // Drives the OpenRouter row's "Key connected" badge.
56
58
  if (workspace.workspaceId) void apiKeys.load(workspace.workspaceId).catch(() => {})
@@ -269,26 +271,36 @@ const groups = computed<IntegrationGroup[]>(() => {
269
271
  })
270
272
  }
271
273
 
272
- // --- Development (private package registries) -------------------------------
273
- // Gated like observability: hidden until a probe confirms the module is wired
274
- // (`available === true`), so an unconfigured backend doesn't show a dead row.
274
+ // --- Development (private package registries + API access tokens) -----------
275
+ // Each row is gated like observability: hidden until a probe confirms its module is
276
+ // wired (`available === true`), so an unconfigured backend doesn't show a dead row.
277
+ const development: IntegrationItem[] = []
275
278
  if (packageRegistries.available) {
276
279
  const hasEntries = packageRegistries.entries.length > 0
277
- out.push({
278
- title: t('layout.integrationsHub.groups.development'),
279
- items: [
280
- {
281
- key: 'package-registries',
282
- icon: 'i-lucide-package',
283
- label: t('layout.integrationsHub.items.packageRegistries.label'),
284
- description: t('layout.integrationsHub.items.packageRegistries.description'),
285
- status: hasEntries ? t('layout.integrationsHub.status.connected') : undefined,
286
- connected: hasEntries,
287
- onClick: () => go(ui.openPackageRegistries),
288
- },
289
- ],
280
+ development.push({
281
+ key: 'package-registries',
282
+ icon: 'i-lucide-package',
283
+ label: t('layout.integrationsHub.items.packageRegistries.label'),
284
+ description: t('layout.integrationsHub.items.packageRegistries.description'),
285
+ status: hasEntries ? t('layout.integrationsHub.status.connected') : undefined,
286
+ connected: hasEntries,
287
+ onClick: () => go(ui.openPackageRegistries),
288
+ })
289
+ }
290
+ if (publicApiKeys.available) {
291
+ const hasKeys = publicApiKeys.keys.length > 0
292
+ development.push({
293
+ key: 'api-tokens',
294
+ icon: 'i-lucide-key-round',
295
+ label: t('layout.integrationsHub.items.apiTokens.label'),
296
+ description: t('layout.integrationsHub.items.apiTokens.description'),
297
+ status: hasKeys ? t('layout.integrationsHub.status.connected') : undefined,
298
+ connected: hasKeys,
299
+ onClick: () => go(ui.openApiTokens),
290
300
  })
291
301
  }
302
+ if (development.length)
303
+ out.push({ title: t('layout.integrationsHub.groups.development'), items: development })
292
304
 
293
305
  // NOTE: Infrastructure (agent-container execution + Tester environments + the local-mode
294
306
  // warm pool/checkout) is no longer listed here — it moved to its OWN top-level navbar menu
@@ -0,0 +1,205 @@
1
+ <script setup lang="ts">
2
+ // API access tokens — the workspace's inbound public-API keys external systems present to the
3
+ // `/api/v1` surface (`Authorization: Bearer cf_live_…`). Keys are hashed one-way server-side,
4
+ // so the raw secret is shown EXACTLY ONCE, on create; the list thereafter renders metadata
5
+ // only (label + created / last-used). To rotate a token, revoke it and mint a new one.
6
+ // Opened from the Integrations hub.
7
+ import { computed, ref, watch } from 'vue'
8
+ import type { PublicApiKey } from '~/types/publicApiKeys'
9
+ import IntegrationBackTitle from '~/components/layout/IntegrationBackTitle.vue'
10
+ import CopyButton from '~/components/common/CopyButton.vue'
11
+
12
+ const { t, d } = useI18n()
13
+ const ui = useUiStore()
14
+ const store = usePublicApiKeysStore()
15
+ const toast = useToast()
16
+ const { confirmAction, toastDone } = useConfirmAction()
17
+
18
+ const open = computed({
19
+ get: () => ui.apiTokensOpen,
20
+ set: (v: boolean) => (v ? ui.openApiTokens() : ui.closeApiTokens()),
21
+ })
22
+ const back = useIntegrationBack(open)
23
+
24
+ const label = ref('')
25
+ const busy = ref(false)
26
+ // The full raw secret from the most recent create — surfaced once, then dismissed. Never
27
+ // re-fetchable, so it lives only in this transient ref (not the store).
28
+ const newSecret = ref<string | null>(null)
29
+
30
+ function notifyError(title: string, e: unknown) {
31
+ toast.add({
32
+ title,
33
+ description: e instanceof Error ? e.message : String(e),
34
+ icon: 'i-lucide-triangle-alert',
35
+ color: 'error',
36
+ })
37
+ }
38
+
39
+ watch(
40
+ open,
41
+ async (isOpen) => {
42
+ if (!isOpen) {
43
+ // Never leave a revealed secret hanging around once the panel closes.
44
+ newSecret.value = null
45
+ return
46
+ }
47
+ try {
48
+ await store.ensureLoaded()
49
+ } catch (e) {
50
+ notifyError(t('settings.apiTokens.toast.loadFailed'), e)
51
+ }
52
+ },
53
+ { immediate: true },
54
+ )
55
+
56
+ async function createToken() {
57
+ const trimmed = label.value.trim()
58
+ if (!trimmed) return
59
+ busy.value = true
60
+ try {
61
+ const created = await store.create(trimmed)
62
+ newSecret.value = created.secret
63
+ label.value = ''
64
+ toast.add({
65
+ title: t('settings.apiTokens.toast.created'),
66
+ icon: 'i-lucide-check',
67
+ color: 'success',
68
+ })
69
+ } catch (e) {
70
+ notifyError(t('settings.apiTokens.toast.createFailed'), e)
71
+ } finally {
72
+ busy.value = false
73
+ }
74
+ }
75
+
76
+ function dismissSecret() {
77
+ newSecret.value = null
78
+ }
79
+
80
+ async function revokeToken(key: PublicApiKey) {
81
+ if (!(await confirmAction('revoke', key.label))) return
82
+ busy.value = true
83
+ try {
84
+ await store.revoke(key.id)
85
+ toastDone('revoke', key.label)
86
+ } catch (e) {
87
+ notifyError(t('settings.apiTokens.toast.revokeFailed'), e)
88
+ } finally {
89
+ busy.value = false
90
+ }
91
+ }
92
+ </script>
93
+
94
+ <template>
95
+ <UModal v-model:open="open" :title="t('settings.apiTokens.title')" :ui="{ content: 'max-w-lg' }">
96
+ <template #title>
97
+ <IntegrationBackTitle :title="t('settings.apiTokens.title')" @back="back" />
98
+ </template>
99
+ <template #body>
100
+ <div class="space-y-4" data-testid="api-tokens-panel">
101
+ <p class="text-sm text-slate-400">
102
+ {{ t('settings.apiTokens.intro') }}
103
+ </p>
104
+
105
+ <!-- One-time secret reveal: shown once after create, dismissed by the user. The full
106
+ key is never recoverable, so it must be copied now. -->
107
+ <section
108
+ v-if="newSecret"
109
+ class="space-y-2 rounded-lg border border-primary-500/40 bg-primary-500/10 p-3"
110
+ data-testid="api-token-secret"
111
+ >
112
+ <div class="flex items-center gap-2 text-sm font-medium text-primary-200">
113
+ <UIcon name="i-lucide-key-round" class="h-4 w-4 shrink-0" />
114
+ <span>{{ t('settings.apiTokens.secret.heading') }}</span>
115
+ </div>
116
+ <p class="text-xs text-slate-300">{{ t('settings.apiTokens.secret.warning') }}</p>
117
+ <div
118
+ class="flex items-center gap-2 rounded-md border border-slate-700 bg-slate-950/60 px-3 py-2"
119
+ >
120
+ <code class="min-w-0 flex-1 truncate font-mono text-xs text-slate-100">{{
121
+ newSecret
122
+ }}</code>
123
+ <CopyButton :text="newSecret" :label="t('settings.apiTokens.secret.copy')" size="sm" />
124
+ </div>
125
+ <div class="flex justify-end">
126
+ <UButton
127
+ color="neutral"
128
+ variant="ghost"
129
+ size="xs"
130
+ data-testid="api-token-secret-dismiss"
131
+ @click="dismissSecret"
132
+ >
133
+ {{ t('settings.apiTokens.secret.done') }}
134
+ </UButton>
135
+ </div>
136
+ </section>
137
+
138
+ <section v-if="store.keys.length" class="space-y-2 rounded-lg border border-slate-700 p-3">
139
+ <h3 class="text-sm font-semibold">
140
+ {{ t('settings.apiTokens.list.heading') }}
141
+ </h3>
142
+ <div
143
+ v-for="key in store.keys"
144
+ :key="key.id"
145
+ class="flex items-center justify-between gap-2 rounded-md border border-slate-800 px-3 py-2"
146
+ >
147
+ <div class="min-w-0 space-y-0.5">
148
+ <div class="truncate text-sm font-medium">{{ key.label }}</div>
149
+ <div class="text-[11px] text-slate-500">
150
+ {{
151
+ t('settings.apiTokens.list.created', {
152
+ date: d(new Date(key.createdAt), 'short'),
153
+ })
154
+ }}
155
+ <span aria-hidden="true"> · </span>
156
+ <template v-if="key.lastUsedAt">{{
157
+ t('settings.apiTokens.list.lastUsed', {
158
+ date: d(new Date(key.lastUsedAt), 'short'),
159
+ })
160
+ }}</template>
161
+ <template v-else>{{ t('settings.apiTokens.list.neverUsed') }}</template>
162
+ </div>
163
+ </div>
164
+ <UButton
165
+ color="error"
166
+ variant="ghost"
167
+ icon="i-lucide-ban"
168
+ size="sm"
169
+ :loading="busy"
170
+ :data-testid="`api-token-revoke-${key.id}`"
171
+ :aria-label="t('settings.apiTokens.list.revoke')"
172
+ @click="revokeToken(key)"
173
+ />
174
+ </div>
175
+ </section>
176
+
177
+ <section class="space-y-3 rounded-lg border border-slate-700 p-3">
178
+ <h3 class="text-sm font-semibold">
179
+ {{ t('settings.apiTokens.add.heading') }}
180
+ </h3>
181
+ <UFormField
182
+ :label="t('settings.apiTokens.add.label')"
183
+ :help="t('settings.apiTokens.add.labelHelp')"
184
+ >
185
+ <UInput
186
+ v-model="label"
187
+ :placeholder="t('settings.apiTokens.add.labelPlaceholder')"
188
+ class="w-full"
189
+ data-testid="api-token-label"
190
+ @keyup.enter="createToken"
191
+ />
192
+ </UFormField>
193
+ <UButton
194
+ :loading="busy"
195
+ :disabled="!label.trim()"
196
+ data-testid="api-token-create"
197
+ @click="createToken"
198
+ >
199
+ {{ t('settings.apiTokens.add.create') }}
200
+ </UButton>
201
+ </section>
202
+ </div>
203
+ </template>
204
+ </UModal>
205
+ </template>
@@ -0,0 +1,26 @@
1
+ import {
2
+ createPublicApiKeyContract,
3
+ listPublicApiKeysContract,
4
+ revokePublicApiKeyContract,
5
+ } from '@cat-factory/contracts'
6
+ import type { CreatePublicApiKeyInput } from '~/types/publicApiKeys'
7
+ import type { ApiContext } from './context'
8
+
9
+ /**
10
+ * Inbound public-API keys ("API access tokens") a workspace mints for external systems to
11
+ * call the `/api/v1` surface. Management routes are session-authed under
12
+ * `/workspaces/:workspaceId`; the raw secret comes back only on create. See
13
+ * PublicApiKeyController.
14
+ */
15
+ export function publicApiKeysApi({ send, ws }: ApiContext) {
16
+ return {
17
+ listPublicApiKeys: (workspaceId: string) =>
18
+ send(listPublicApiKeysContract, { pathPrefix: ws(workspaceId) }),
19
+
20
+ createPublicApiKey: (workspaceId: string, body: CreatePublicApiKeyInput) =>
21
+ send(createPublicApiKeyContract, { pathPrefix: ws(workspaceId), body }),
22
+
23
+ revokePublicApiKey: (workspaceId: string, id: string) =>
24
+ send(revokePublicApiKeyContract, { pathPrefix: ws(workspaceId), pathParams: { id } }),
25
+ }
26
+ }
@@ -25,6 +25,7 @@ import { notificationsApi } from './api/notifications'
25
25
  import { packageRegistriesApi } from './api/packageRegistries'
26
26
  import { preflightsApi } from './api/preflights'
27
27
  import { presetsApi } from './api/presets'
28
+ import { publicApiKeysApi } from './api/publicApiKeys'
28
29
  import { sharedStacksApi } from './api/sharedStacks'
29
30
  import { providerConnectionsApi } from './api/providerConnections'
30
31
  import { provisioningLogsApi } from './api/provisioningLogs'
@@ -121,6 +122,7 @@ export function useApi() {
121
122
  ...notificationsApi(ctx),
122
123
  ...presetsApi(ctx),
123
124
  ...preflightsApi(ctx),
125
+ ...publicApiKeysApi(ctx),
124
126
  ...sharedStacksApi(ctx),
125
127
  ...providerConnectionsApi(ctx),
126
128
  ...infraHandlersApi(ctx),
@@ -96,6 +96,9 @@ const ObservabilityConnectionPanel = defineAsyncComponent(
96
96
  const PackageRegistriesPanel = defineAsyncComponent(
97
97
  () => import('~/components/settings/PackageRegistriesPanel.vue'),
98
98
  )
99
+ const ApiTokensPanel = defineAsyncComponent(
100
+ () => import('~/components/settings/ApiTokensPanel.vue'),
101
+ )
99
102
  const InfrastructureWindow = defineAsyncComponent(
100
103
  () => import('~/components/settings/InfrastructureWindow.vue'),
101
104
  )
@@ -402,6 +405,7 @@ watch(
402
405
  <AccountSettingsPanel v-if="ui.accountSettingsOpen" />
403
406
  <ObservabilityConnectionPanel v-if="ui.observabilityConnectionOpen" />
404
407
  <PackageRegistriesPanel v-if="ui.packageRegistriesOpen" />
408
+ <ApiTokensPanel v-if="ui.apiTokensOpen" />
405
409
  <InfrastructureWindow v-if="ui.infrastructureOpen" />
406
410
  <EnvironmentSetupWizard v-if="ui.environmentWizardOpen" />
407
411
  <ModelConfigurationPanel v-if="ui.modelConfigOpen" />
@@ -0,0 +1,105 @@
1
+ import { describe, it, expect, beforeEach, vi } from 'vitest'
2
+ import { usePublicApiKeysStore } from '~/stores/publicApiKeys'
3
+ import { useWorkspaceStore } from '~/stores/workspace'
4
+ import type { CreatedPublicApiKey, PublicApiKey } from '~/types/publicApiKeys'
5
+
6
+ /** Minimal metadata-view factory — only the fields the store passes through. */
7
+ function key(over: Partial<PublicApiKey> = {}): PublicApiKey {
8
+ return {
9
+ id: 'pak_1',
10
+ accountId: 'acc1',
11
+ workspaceId: 'ws1',
12
+ label: 'CI',
13
+ createdAt: 1,
14
+ lastUsedAt: null,
15
+ revokedAt: null,
16
+ ...over,
17
+ }
18
+ }
19
+
20
+ describe('publicApiKeys store', () => {
21
+ beforeEach(() => {
22
+ useWorkspaceStore().workspaceId = 'ws1'
23
+ })
24
+
25
+ it('load stores the key list and marks the feature available', async () => {
26
+ vi.stubGlobal('useApi', () => ({
27
+ listPublicApiKeys: () => Promise.resolve({ keys: [key()] }),
28
+ }))
29
+
30
+ const store = usePublicApiKeysStore()
31
+ await store.load()
32
+
33
+ expect(store.available).toBe(true)
34
+ expect(store.keys).toHaveLength(1)
35
+ expect(store.loading).toBe(false)
36
+ })
37
+
38
+ it('a definitive 503 latches the feature unavailable and clears the list', async () => {
39
+ vi.stubGlobal('useApi', () => ({
40
+ listPublicApiKeys: () => Promise.reject({ statusCode: 503 }),
41
+ }))
42
+
43
+ const store = usePublicApiKeysStore()
44
+ await store.load()
45
+
46
+ expect(store.available).toBe(false)
47
+ expect(store.keys).toEqual([])
48
+ })
49
+
50
+ it('a transient failure leaves `available` null so ensureLoaded stays retryable', async () => {
51
+ vi.stubGlobal('useApi', () => ({
52
+ listPublicApiKeys: () => Promise.reject({ statusCode: 500 }),
53
+ }))
54
+
55
+ const store = usePublicApiKeysStore()
56
+ await store.load()
57
+
58
+ // Never latched — a network/5xx blip must not hide an otherwise-available panel.
59
+ expect(store.available).toBeNull()
60
+ })
61
+
62
+ it('ensureLoaded coalesces concurrent callers into one request', async () => {
63
+ const list = vi.fn(() => Promise.resolve({ keys: [key()] }))
64
+ vi.stubGlobal('useApi', () => ({ listPublicApiKeys: list }))
65
+
66
+ const store = usePublicApiKeysStore()
67
+ await Promise.all([store.ensureLoaded(), store.ensureLoaded()])
68
+ // And once probed, it never re-fetches.
69
+ await store.ensureLoaded()
70
+
71
+ expect(list).toHaveBeenCalledTimes(1)
72
+ })
73
+
74
+ it('create prepends the new key (newest-first) and returns the one-time secret', async () => {
75
+ const created: CreatedPublicApiKey = {
76
+ key: key({ id: 'pak_new', label: 'deploy' }),
77
+ secret: 'cf_live_pak_new.abc',
78
+ }
79
+ vi.stubGlobal('useApi', () => ({
80
+ listPublicApiKeys: () => Promise.resolve({ keys: [key({ id: 'pak_old' })] }),
81
+ createPublicApiKey: () => Promise.resolve(created),
82
+ }))
83
+
84
+ const store = usePublicApiKeysStore()
85
+ await store.load()
86
+ const result = await store.create('deploy')
87
+
88
+ expect(result.secret).toBe('cf_live_pak_new.abc')
89
+ expect(store.keys.map((k) => k.id)).toEqual(['pak_new', 'pak_old'])
90
+ expect(store.available).toBe(true)
91
+ })
92
+
93
+ it('revoke drops the key from the list', async () => {
94
+ vi.stubGlobal('useApi', () => ({
95
+ listPublicApiKeys: () => Promise.resolve({ keys: [key({ id: 'a' }), key({ id: 'b' })] }),
96
+ revokePublicApiKey: () => Promise.resolve(),
97
+ }))
98
+
99
+ const store = usePublicApiKeysStore()
100
+ await store.load()
101
+ await store.revoke('a')
102
+
103
+ expect(store.keys.map((k) => k.id)).toEqual(['b'])
104
+ })
105
+ })
@@ -0,0 +1,72 @@
1
+ import { defineStore } from 'pinia'
2
+ import { ref } from 'vue'
3
+ import type { CreatedPublicApiKey, PublicApiKey } from '~/types/publicApiKeys'
4
+ import { useWorkspaceStore } from '~/stores/workspace'
5
+ import { apiErrorStatus } from '~/composables/api/errors'
6
+
7
+ /**
8
+ * The workspace's inbound public-API keys ("API access tokens") external systems present
9
+ * to the `/api/v1` surface. Secrets are one-way hashed server-side and returned only once
10
+ * on create, so the store holds metadata-only views; the raw secret is surfaced by the
11
+ * caller from the `create()` result. Loaded on demand (the tokens panel + the Integrations
12
+ * hub badge), not from the snapshot.
13
+ */
14
+ export const usePublicApiKeysStore = defineStore('publicApiKeys', () => {
15
+ const api = useApi()
16
+
17
+ const keys = ref<PublicApiKey[]>([])
18
+ const loading = ref(false)
19
+ // Mirrors the backend's opt-in gate (the module 503s when the encryption key is absent):
20
+ // `null` until first probed, then `true`/`false`. The hub hides its tokens entry point
21
+ // when this is false.
22
+ const available = ref<boolean | null>(null)
23
+ let inFlight: Promise<void> | null = null
24
+
25
+ /** Force a refresh of the key list (used after a create/revoke). */
26
+ async function load() {
27
+ const ws = useWorkspaceStore()
28
+ loading.value = true
29
+ try {
30
+ keys.value = (await api.listPublicApiKeys(ws.requireId())).keys
31
+ available.value = true
32
+ } catch (err) {
33
+ if (apiErrorStatus(err) === 503) {
34
+ // A definitive 503 means the feature is unconfigured (no encryption key on the
35
+ // backend): hide the UI entry points and stop probing.
36
+ available.value = false
37
+ keys.value = []
38
+ }
39
+ // Any other failure (transient 5xx / network) is left untouched: it must not hide an
40
+ // already-available panel nor cache a false "unavailable". `available` stays `null`
41
+ // when never probed, so `ensureLoaded` remains retryable on the next open.
42
+ } finally {
43
+ loading.value = false
44
+ }
45
+ }
46
+
47
+ /** Load once and share the result (coalescing concurrent callers); `load()` refreshes. */
48
+ async function ensureLoaded() {
49
+ if (available.value !== null) return
50
+ if (!inFlight) inFlight = load().finally(() => (inFlight = null))
51
+ return inFlight
52
+ }
53
+
54
+ /** Mint a key. Returns the created record PLUS the one-time raw secret (shown once). */
55
+ async function create(label: string): Promise<CreatedPublicApiKey> {
56
+ const ws = useWorkspaceStore()
57
+ const created = await api.createPublicApiKey(ws.requireId(), { label })
58
+ // Prepend: the backend lists newest-first, so the freshly minted key belongs at the
59
+ // top — matching the order a subsequent `load()` would produce.
60
+ keys.value = [created.key, ...keys.value]
61
+ available.value = true
62
+ return created
63
+ }
64
+
65
+ async function revoke(id: string) {
66
+ const ws = useWorkspaceStore()
67
+ await api.revokePublicApiKey(ws.requireId(), id)
68
+ keys.value = keys.value.filter((k) => k.id !== id)
69
+ }
70
+
71
+ return { keys, loading, available, load, ensureLoaded, create, revoke }
72
+ })