@cat-factory/app 0.317.2 → 0.318.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.
@@ -172,6 +172,7 @@ watch(
172
172
  newSecret.value = null
173
173
  return
174
174
  }
175
+ applyPrefill()
175
176
  try {
176
177
  await store.ensureLoaded()
177
178
  } catch (e) {
@@ -181,6 +182,16 @@ watch(
181
182
  { immediate: true },
182
183
  )
183
184
 
185
+ /** Fill the create form from a new-API-key deep link (`useNewApiKeyDeepLink`), once. */
186
+ function applyPrefill() {
187
+ const prefill = ui.apiTokensPrefill
188
+ if (!prefill) return
189
+ if (prefill.label) label.value = prefill.label
190
+ if (prefill.scope) scope.value = prefill.scope
191
+ if (prefill.runsAsSelf && canBindSelf.value) identity.value = 'self'
192
+ ui.apiTokensPrefill = null
193
+ }
194
+
184
195
  async function createToken() {
185
196
  const trimmed = label.value.trim()
186
197
  if (!trimmed) return
@@ -0,0 +1,53 @@
1
+ import { createPinia, setActivePinia } from 'pinia'
2
+ import { beforeEach, describe, expect, it } from 'vitest'
3
+ import { captureNewApiKeyDeepLink } from './useNewApiKeyDeepLink'
4
+
5
+ function setUrl(search: string): void {
6
+ history.replaceState(null, '', `/${search}`)
7
+ }
8
+
9
+ describe('captureNewApiKeyDeepLink', () => {
10
+ beforeEach(() => {
11
+ setActivePinia(createPinia())
12
+ setUrl('')
13
+ })
14
+
15
+ it('parses the link an integration builds, and strips only its own params', () => {
16
+ setUrl('?newApiKey=1&label=sainte-beuve&scope=decide&runsAsSelf=1&ws=ws_1&keep=yes')
17
+ expect(captureNewApiKeyDeepLink()).toEqual({
18
+ label: 'sainte-beuve',
19
+ scope: 'decide',
20
+ runsAsSelf: true,
21
+ workspaceId: 'ws_1',
22
+ })
23
+ expect(window.location.search).toBe('?keep=yes')
24
+ expect(captureNewApiKeyDeepLink()).toBeNull()
25
+ })
26
+
27
+ it('opens the form at its defaults when the link names nothing else', () => {
28
+ setUrl('?newApiKey=1')
29
+ expect(captureNewApiKeyDeepLink()).toEqual({
30
+ label: null,
31
+ scope: null,
32
+ runsAsSelf: false,
33
+ workspaceId: null,
34
+ })
35
+ expect(window.location.search).toBe('')
36
+ })
37
+
38
+ it('drops a scope this build does not know rather than guessing one', () => {
39
+ setUrl('?newApiKey=1&scope=superuser')
40
+ expect(captureNewApiKeyDeepLink()).toMatchObject({ scope: null })
41
+ })
42
+
43
+ it('cuts a label to what the form accepts', () => {
44
+ setUrl(`?newApiKey=1&label=${'x'.repeat(200)}`)
45
+ expect(captureNewApiKeyDeepLink()?.label).toHaveLength(120)
46
+ })
47
+
48
+ it('ignores a URL without the link, and leaves its query alone', () => {
49
+ setUrl('?label=other&scope=admin')
50
+ expect(captureNewApiKeyDeepLink()).toBeNull()
51
+ expect(window.location.search).toBe('?label=other&scope=admin')
52
+ })
53
+ })
@@ -0,0 +1,87 @@
1
+ /**
2
+ * Boot-time replay of a NEW API KEY deep link:
3
+ * `?newApiKey=1&label=<label>&scope=<scope>&runsAsSelf=1&ws=<id>`.
4
+ *
5
+ * An integration that needs a person's OWN key (one bound to them, which is what lets a run use
6
+ * their personal model subscription) cannot mint it for them: a bound key can only be minted by
7
+ * its owner, signed in. It sends the person here instead, and the API tokens window opens with the
8
+ * create form filled in. Nothing is minted until the person clicks create.
9
+ *
10
+ * Every parameter but `newApiKey` is optional. A scope this build does not know is dropped rather
11
+ * than guessed at, and `runsAsSelf` only takes effect where there is a signed-in person to bind
12
+ * (`ApiTokensPanel` withholds the choice otherwise). Signing in first keeps the query
13
+ * (`postSignInUrl`). The format is published next to `?addRepo=` in the public API docs.
14
+ */
15
+ import { PUBLIC_API_SCOPES } from '@cat-factory/contracts'
16
+ import { watch } from 'vue'
17
+ import type { PublicApiScope } from '~/types/publicApiKeys'
18
+ import { useUiStore } from '../stores/ui'
19
+ import { useWorkspaceStore } from '../stores/workspace'
20
+
21
+ /** What the API tokens window's create form is opened with. Absent fields keep their defaults. */
22
+ export interface NewApiKeyPrefill {
23
+ label: string | null
24
+ scope: PublicApiScope | null
25
+ runsAsSelf: boolean
26
+ }
27
+
28
+ interface PendingNewApiKeyLink extends NewApiKeyPrefill {
29
+ workspaceId: string | null
30
+ }
31
+
32
+ const NEW_API_KEY_LINK_PARAMS = ['newApiKey', 'label', 'scope', 'runsAsSelf', 'ws'] as const
33
+
34
+ /** The form's own label limit, so a prefill never starts out invalid. */
35
+ const LABEL_MAX_LENGTH = 120
36
+
37
+ function scopeOf(value: string | null): PublicApiScope | null {
38
+ return (PUBLIC_API_SCOPES as readonly string[]).includes(value ?? '')
39
+ ? (value as PublicApiScope)
40
+ : null
41
+ }
42
+
43
+ /**
44
+ * Read (and strip) a new-API-key link from the current URL, or null when there is none. Called
45
+ * before `workspace.init()` so `ws` can pin the board.
46
+ */
47
+ export function captureNewApiKeyDeepLink(): PendingNewApiKeyLink | null {
48
+ if (typeof window === 'undefined') return null
49
+ const params = new URLSearchParams(window.location.search)
50
+ if (params.get('newApiKey') !== '1') return null
51
+ const label = params.get('label')?.trim().slice(0, LABEL_MAX_LENGTH) || null
52
+ const link: PendingNewApiKeyLink = {
53
+ label,
54
+ scope: scopeOf(params.get('scope')),
55
+ runsAsSelf: params.get('runsAsSelf') === '1',
56
+ workspaceId: params.get('ws'),
57
+ }
58
+ for (const key of NEW_API_KEY_LINK_PARAMS) params.delete(key)
59
+ const qs = params.toString()
60
+ history.replaceState(null, '', window.location.pathname + (qs ? `?${qs}` : ''))
61
+ return link
62
+ }
63
+
64
+ /** Capture a new-API-key link and, once the board has hydrated, open the window with it. */
65
+ export function useNewApiKeyDeepLink(): void {
66
+ const link = captureNewApiKeyDeepLink()
67
+ if (!link) return
68
+
69
+ const workspace = useWorkspaceStore()
70
+ const ui = useUiStore()
71
+ if (link.workspaceId) workspace.workspaceId = link.workspaceId
72
+
73
+ // One-shot, as `useAddRepoDeepLink` is: a later refresh must not reopen a window the person
74
+ // closed, and `immediate` runs the callback before `stop` is bound.
75
+ let applied = false
76
+ let stop: (() => void) | undefined
77
+ stop = watch(
78
+ () => workspace.ready,
79
+ (ready) => {
80
+ if (!ready || applied) return
81
+ applied = true
82
+ ui.openApiTokens({ label: link.label, scope: link.scope, runsAsSelf: link.runsAsSelf })
83
+ stop?.()
84
+ },
85
+ { immediate: true },
86
+ )
87
+ }
@@ -185,6 +185,8 @@ onMounted(() => {
185
185
  useRunDeepLink()
186
186
  // `?addRepo=<owner>/<name>`: an integration sending a person to add a service for a repository.
187
187
  useAddRepoDeepLink()
188
+ // `?newApiKey=1`: an integration sending a person to mint a key bound to them.
189
+ useNewApiKeyDeepLink()
188
190
  // `?view=queue` / `?view=board`: a link that opens one of the board's two views.
189
191
  views.consumeDeepLink()
190
192
  void workspace.init()
@@ -11,6 +11,7 @@ import type {
11
11
  ProviderConnectionKind,
12
12
  } from '~/types/providerConnections'
13
13
  import type { AddServicePrefill } from '~/composables/useAddRepoDeepLink'
14
+ import type { NewApiKeyPrefill } from '~/composables/useNewApiKeyDeepLink'
14
15
  import type { PendingContext } from '~/composables/useContextLinking'
15
16
  import {
16
17
  infraSetupDismissalKey,
@@ -667,6 +668,8 @@ function createIntegrationPanelModals(resetHubReturn: ResetHubReturn) {
667
668
  // API access tokens: the workspace's inbound public-API keys external systems present to
668
669
  // the `/api/v1` surface. Opened from the Integrations hub.
669
670
  const apiTokensOpen = ref(false)
671
+ // The create form a new-API-key deep link opened the window with, consumed on open.
672
+ const apiTokensPrefill = ref<NewApiKeyPrefill | null>(null)
670
673
  // Opened and closed by `createBuilderModelConfigHandoff`, which hands the pipeline builder over.
671
674
  const modelConfigOpen = ref(false)
672
675
  // LLM-vendor subscription credentials (the token pool powering the Claude Code
@@ -722,12 +725,14 @@ function createIntegrationPanelModals(resetHubReturn: ResetHubReturn) {
722
725
  function closeReports() {
723
726
  reportsOpen.value = false
724
727
  }
725
- function openApiTokens() {
728
+ function openApiTokens(prefill: NewApiKeyPrefill | null = null) {
726
729
  resetHubReturn()
730
+ apiTokensPrefill.value = prefill
727
731
  apiTokensOpen.value = true
728
732
  }
729
733
  function closeApiTokens() {
730
734
  apiTokensOpen.value = false
735
+ apiTokensPrefill.value = null
731
736
  }
732
737
  function openVendorCredentials(tab = 'pool') {
733
738
  resetHubReturn()
@@ -770,6 +775,7 @@ function createIntegrationPanelModals(resetHubReturn: ResetHubReturn) {
770
775
  operatorDashboardOpen,
771
776
  reportsOpen,
772
777
  apiTokensOpen,
778
+ apiTokensPrefill,
773
779
  modelConfigOpen,
774
780
  vendorCredentialsOpen,
775
781
  vendorCredentialsTab,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@cat-factory/app",
3
- "version": "0.317.2",
3
+ "version": "0.318.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",