@cat-factory/app 0.317.2 → 0.319.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.
@@ -43,23 +43,66 @@ const store = usePublicApiKeysStore()
43
43
  const accounts = useAccountsStore()
44
44
  const workspace = useWorkspaceStore()
45
45
 
46
- // A key's reach. The default is this board alone. The wider choices are an account admin's to
47
- // make, so the field is shown to everyone in both UI modes and disabled for anyone else, with the
48
- // reason, because a directory webhook needs a key that reaches every workspace.
49
- type TokenReach = 'this' | 'all' | 'selected'
46
+ // WHO the next key runs as. Two named options rather than an unchecked box, because they are two
47
+ // different credentials with two different blast radii and neither is a mere setting on the other:
48
+ //
49
+ // - `system` (the default): the token belongs to the workspace. Its runs are attributed to nobody
50
+ // and it can reach no personal subscription, so a leaked shared credential can never spend one
51
+ // person's Claude quota. This is what a CI job or a shared integration should hold.
52
+ // - `self`: the token belongs to the person minting it. Its runs are theirs and may unlock their
53
+ // personal Claude / Codex / GLM subscription, with the password sent on each such call.
54
+ //
55
+ // NOT gated on the interface mode, unlike an override field. This whole panel is the Integrations
56
+ // hub's Development surface, reached only by someone already minting an API key, and `scope` next
57
+ // to it is ungated for the same reason. Hiding it in basic mode would leave that person a key that
58
+ // silently cannot run their own models, with nothing on screen to say why.
59
+ type TokenIdentity = 'system' | 'self'
60
+ const identity = ref<TokenIdentity>('system')
61
+ // Nobody to bind on a board with no signed-in user (a dev-open deployment): the server refuses
62
+ // such a mint outright, so the choice is withheld and every key is a system key, which is what
63
+ // that deployment can honestly offer.
64
+ const canBindSelf = computed(() => auth.user !== null && auth.user !== undefined)
65
+ const identityItems = computed(() => [
66
+ { value: 'system' as const, label: t('settings.apiTokens.add.identitySystem') },
67
+ { value: 'self' as const, label: t('settings.apiTokens.add.identitySelf') },
68
+ ])
69
+
70
+ // A key's reach. The default is this board alone. 'all' and 'selected' are an account admin's to
71
+ // make, because a directory webhook needs a key that reaches every workspace. 'memberships' is
72
+ // anyone's for a key that runs as them: it reaches whatever they can act in, call by call, and
73
+ // never more than their role allows in each, so it is the default for such a key.
74
+ type TokenReach = 'this' | 'all' | 'selected' | 'memberships'
50
75
  const reach = ref<TokenReach>('this')
51
76
  const selectedWorkspaceIds = ref<string[]>([])
52
77
  const canWidenReach = computed(() => accounts.activeAccount?.roles?.includes('admin') === true)
53
78
  const reachItems = computed(() => [
54
79
  { value: 'this' as const, label: t('settings.apiTokens.add.reachThis') },
55
- { value: 'all' as const, label: t('settings.apiTokens.add.reachAll') },
56
- { value: 'selected' as const, label: t('settings.apiTokens.add.reachSelected') },
80
+ ...(identity.value === 'self'
81
+ ? [{ value: 'memberships' as const, label: t('settings.apiTokens.add.reachMemberships') }]
82
+ : []),
83
+ {
84
+ value: 'all' as const,
85
+ label: t('settings.apiTokens.add.reachAll'),
86
+ disabled: !canWidenReach.value,
87
+ },
88
+ {
89
+ value: 'selected' as const,
90
+ label: t('settings.apiTokens.add.reachSelected'),
91
+ disabled: !canWidenReach.value,
92
+ },
57
93
  ])
94
+ const reachHelp = computed(() => {
95
+ if (reach.value === 'memberships') return t('settings.apiTokens.add.reachMembershipsHelp')
96
+ return canWidenReach.value
97
+ ? t('settings.apiTokens.add.reachHelp')
98
+ : t('settings.apiTokens.add.reachAdminOnly')
99
+ })
58
100
  const workspaceItems = computed(() =>
59
101
  workspace.accountWorkspaces.map((w) => ({ value: w.id, label: w.name })),
60
102
  )
61
- /** The `workspaceIds` to mint with: omitted for this board, `null` for all, or the picked list. */
62
- function requestedReach(): string[] | null | undefined {
103
+ /** The reach to mint with: omitted for this board, `null` for all, the picked list, or memberships. */
104
+ function requestedReach(): string[] | null | 'memberships' | undefined {
105
+ if (reach.value === 'memberships') return identity.value === 'self' ? 'memberships' : undefined
63
106
  if (!canWidenReach.value || reach.value === 'this') return undefined
64
107
  if (reach.value === 'all') return null
65
108
  return selectedWorkspaceIds.value
@@ -73,6 +116,7 @@ const reachIncomplete = computed(
73
116
 
74
117
  /** The reach badge for a listed key; none for a key reaching this one board. */
75
118
  function reachLabel(key: PublicApiKey): string | null {
119
+ if (key.followsMemberships) return t('settings.apiTokens.list.reachMemberships')
76
120
  if (key.workspaceIds === null) return t('settings.apiTokens.list.reachAll')
77
121
  if (key.workspaceIds.length > 1) {
78
122
  return t('settings.apiTokens.list.reachCount', { count: key.workspaceIds.length })
@@ -130,29 +174,13 @@ const back = useIntegrationBack(open)
130
174
  const label = ref('')
131
175
  // The scope the next minted key will carry; defaults to the safe middle of the ladder.
132
176
  const scope = ref<PublicApiScope>('write')
133
- // WHO the next key runs as. Two named options rather than an unchecked box, because they are two
134
- // different credentials with two different blast radii and neither is a mere setting on the other:
135
- //
136
- // - `system` (the default): the token belongs to the workspace. Its runs are attributed to nobody
137
- // and it can reach no personal subscription, so a leaked shared credential can never spend one
138
- // person's Claude quota. This is what a CI job or a shared integration should hold.
139
- // - `self`: the token belongs to the person minting it. Its runs are theirs and may unlock their
140
- // personal Claude / Codex / GLM subscription, with the password sent on each such call.
141
- //
142
- // NOT gated on the interface mode, unlike an override field. This whole panel is the Integrations
143
- // hub's Development surface, reached only by someone already minting an API key, and `scope` next
144
- // to it is ungated for the same reason. Hiding it in basic mode would leave that person a key that
145
- // silently cannot run their own models, with nothing on screen to say why.
146
- type TokenIdentity = 'system' | 'self'
147
- const identity = ref<TokenIdentity>('system')
148
- // Nobody to bind on a board with no signed-in user (a dev-open deployment): the server refuses
149
- // such a mint outright, so the choice is withheld and every key is a system key, which is what
150
- // that deployment can honestly offer.
151
- const canBindSelf = computed(() => auth.user !== null && auth.user !== undefined)
152
- const identityItems = computed(() => [
153
- { value: 'system' as const, label: t('settings.apiTokens.add.identitySystem') },
154
- { value: 'self' as const, label: t('settings.apiTokens.add.identitySelf') },
155
- ])
177
+ // A key that runs as its person follows their workspaces unless they pick otherwise, and one that
178
+ // runs as nobody has no memberships to follow.
179
+ watch(identity, (next) => {
180
+ if (next === 'self' && reach.value === 'this') reach.value = 'memberships'
181
+ if (next === 'system' && reach.value === 'memberships') reach.value = 'this'
182
+ })
183
+
156
184
  /** The help text under the picker, so each option explains ITSELF rather than only its opposite. */
157
185
  const identityHelp = computed(() =>
158
186
  identity.value === 'self'
@@ -172,6 +200,7 @@ watch(
172
200
  newSecret.value = null
173
201
  return
174
202
  }
203
+ applyPrefill()
175
204
  try {
176
205
  await store.ensureLoaded()
177
206
  } catch (e) {
@@ -181,6 +210,17 @@ watch(
181
210
  { immediate: true },
182
211
  )
183
212
 
213
+ /** Fill the create form from a new-API-key deep link (`useNewApiKeyDeepLink`), once. */
214
+ function applyPrefill() {
215
+ const prefill = ui.apiTokensPrefill
216
+ if (!prefill) return
217
+ if (prefill.label) label.value = prefill.label
218
+ if (prefill.scope) scope.value = prefill.scope
219
+ if (prefill.runsAsSelf && canBindSelf.value) identity.value = 'self'
220
+ if (prefill.reach === 'memberships' && identity.value === 'self') reach.value = 'memberships'
221
+ ui.apiTokensPrefill = null
222
+ }
223
+
184
224
  async function createToken() {
185
225
  const trimmed = label.value.trim()
186
226
  if (!trimmed) return
@@ -381,18 +421,11 @@ async function revokeToken(key: PublicApiKey) {
381
421
  data-testid="api-token-identity"
382
422
  />
383
423
  </UFormField>
384
- <UFormField
385
- :label="t('settings.apiTokens.add.reach')"
386
- :help="
387
- canWidenReach
388
- ? t('settings.apiTokens.add.reachHelp')
389
- : t('settings.apiTokens.add.reachAdminOnly')
390
- "
391
- >
424
+ <UFormField :label="t('settings.apiTokens.add.reach')" :help="reachHelp">
392
425
  <USelect
393
426
  v-model="reach"
394
427
  :items="reachItems"
395
- :disabled="!canWidenReach"
428
+ :disabled="!canWidenReach && identity !== 'self'"
396
429
  class="w-full"
397
430
  data-testid="api-token-reach"
398
431
  />
@@ -0,0 +1,62 @@
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(
17
+ '?newApiKey=1&label=sainte-beuve&scope=decide&runsAsSelf=1&reach=memberships&ws=ws_1&keep=yes',
18
+ )
19
+ expect(captureNewApiKeyDeepLink()).toEqual({
20
+ label: 'sainte-beuve',
21
+ scope: 'decide',
22
+ runsAsSelf: true,
23
+ reach: 'memberships',
24
+ workspaceId: 'ws_1',
25
+ })
26
+ expect(window.location.search).toBe('?keep=yes')
27
+ expect(captureNewApiKeyDeepLink()).toBeNull()
28
+ })
29
+
30
+ it('opens the form at its defaults when the link names nothing else', () => {
31
+ setUrl('?newApiKey=1')
32
+ expect(captureNewApiKeyDeepLink()).toEqual({
33
+ label: null,
34
+ scope: null,
35
+ runsAsSelf: false,
36
+ reach: null,
37
+ workspaceId: null,
38
+ })
39
+ expect(window.location.search).toBe('')
40
+ })
41
+
42
+ it('drops a reach this build does not know rather than guessing one', () => {
43
+ setUrl('?newApiKey=1&runsAsSelf=1&reach=everything')
44
+ expect(captureNewApiKeyDeepLink()).toMatchObject({ reach: null })
45
+ })
46
+
47
+ it('drops a scope this build does not know rather than guessing one', () => {
48
+ setUrl('?newApiKey=1&scope=superuser')
49
+ expect(captureNewApiKeyDeepLink()).toMatchObject({ scope: null })
50
+ })
51
+
52
+ it('cuts a label to what the form accepts', () => {
53
+ setUrl(`?newApiKey=1&label=${'x'.repeat(200)}`)
54
+ expect(captureNewApiKeyDeepLink()?.label).toHaveLength(120)
55
+ })
56
+
57
+ it('ignores a URL without the link, and leaves its query alone', () => {
58
+ setUrl('?label=other&scope=admin')
59
+ expect(captureNewApiKeyDeepLink()).toBeNull()
60
+ expect(window.location.search).toBe('?label=other&scope=admin')
61
+ })
62
+ })
@@ -0,0 +1,102 @@
1
+ /**
2
+ * Boot-time replay of a NEW API KEY deep link:
3
+ * `?newApiKey=1&label=<label>&scope=<scope>&runsAsSelf=1&reach=memberships&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
+ /** `memberships`: reach whatever the person can act in. Only with `runsAsSelf`. */
27
+ reach: 'memberships' | null
28
+ }
29
+
30
+ interface PendingNewApiKeyLink extends NewApiKeyPrefill {
31
+ workspaceId: string | null
32
+ }
33
+
34
+ const NEW_API_KEY_LINK_PARAMS = [
35
+ 'newApiKey',
36
+ 'label',
37
+ 'scope',
38
+ 'runsAsSelf',
39
+ 'reach',
40
+ 'ws',
41
+ ] as const
42
+
43
+ /** The form's own label limit, so a prefill never starts out invalid. */
44
+ const LABEL_MAX_LENGTH = 120
45
+
46
+ function scopeOf(value: string | null): PublicApiScope | null {
47
+ return (PUBLIC_API_SCOPES as readonly string[]).includes(value ?? '')
48
+ ? (value as PublicApiScope)
49
+ : null
50
+ }
51
+
52
+ /**
53
+ * Read (and strip) a new-API-key link from the current URL, or null when there is none. Called
54
+ * before `workspace.init()` so `ws` can pin the board.
55
+ */
56
+ export function captureNewApiKeyDeepLink(): PendingNewApiKeyLink | null {
57
+ if (typeof window === 'undefined') return null
58
+ const params = new URLSearchParams(window.location.search)
59
+ if (params.get('newApiKey') !== '1') return null
60
+ const label = params.get('label')?.trim().slice(0, LABEL_MAX_LENGTH) || null
61
+ const link: PendingNewApiKeyLink = {
62
+ label,
63
+ scope: scopeOf(params.get('scope')),
64
+ runsAsSelf: params.get('runsAsSelf') === '1',
65
+ reach: params.get('reach') === 'memberships' ? 'memberships' : null,
66
+ workspaceId: params.get('ws'),
67
+ }
68
+ for (const key of NEW_API_KEY_LINK_PARAMS) params.delete(key)
69
+ const qs = params.toString()
70
+ history.replaceState(null, '', window.location.pathname + (qs ? `?${qs}` : ''))
71
+ return link
72
+ }
73
+
74
+ /** Capture a new-API-key link and, once the board has hydrated, open the window with it. */
75
+ export function useNewApiKeyDeepLink(): void {
76
+ const link = captureNewApiKeyDeepLink()
77
+ if (!link) return
78
+
79
+ const workspace = useWorkspaceStore()
80
+ const ui = useUiStore()
81
+ if (link.workspaceId) workspace.workspaceId = link.workspaceId
82
+
83
+ // One-shot, as `useAddRepoDeepLink` is: a later refresh must not reopen a window the person
84
+ // closed, and `immediate` runs the callback before `stop` is bound.
85
+ let applied = false
86
+ let stop: (() => void) | undefined
87
+ stop = watch(
88
+ () => workspace.ready,
89
+ (ready) => {
90
+ if (!ready || applied) return
91
+ applied = true
92
+ ui.openApiTokens({
93
+ label: link.label,
94
+ scope: link.scope,
95
+ runsAsSelf: link.runsAsSelf,
96
+ reach: link.reach,
97
+ })
98
+ stop?.()
99
+ },
100
+ { immediate: true },
101
+ )
102
+ }
@@ -134,27 +134,6 @@ function navAnchoredSteps(): {
134
134
  )
135
135
  }
136
136
 
137
- /**
138
- * Every combination of the {@link NavGates} booleans, yielded lazily so the 2^N gate sets are
139
- * never all live at once.
140
- *
141
- * Enumerating rather than reasoning is deliberate. The pairing below is an IMPLICATION over
142
- * gate sets — anything that satisfies a tour must also render its entry — between two
143
- * predicates written independently in two files, and nothing about their shape is guaranteed
144
- * (either may be a conjunction, a disjunction, or read a field the other doesn't). The matrix
145
- * doubles with every field {@link NavGates} gains, so the cost is worth re-measuring rather than
146
- * quoting from here: at the field count this spec runs against today it is a fraction of a
147
- * second, a fair price for a guard that needs no assumption about how either side is spelled.
148
- */
149
- function* everyGateSet(): Generator<NavGates> {
150
- const keys = Object.keys(ALL_GATES) as (keyof NavGates)[]
151
- for (let mask = 0; mask < 2 ** keys.length; mask++) {
152
- const gates = {} as Record<keyof NavGates, boolean>
153
- for (const [bit, key] of keys.entries()) gates[key] = (mask & (1 << bit)) !== 0
154
- yield gates as NavGates
155
- }
156
- }
157
-
158
137
  /** The gate fields a witness has turned OFF — the interesting half of a failure message. */
159
138
  function absentGates(gates: NavGates): readonly string[] {
160
139
  return Object.entries(gates)
@@ -162,39 +141,91 @@ function absentGates(gates: NavGates): readonly string[] {
162
141
  .map(([key]) => key)
163
142
  }
164
143
 
144
+ /** One pair's verdict over `gates`: does it offer the tour while the entry is NOT rendered? */
145
+ function drifts(pair: ReturnType<typeof navAnchoredSteps>[number], gates: NavGates): boolean {
146
+ if (!(pair.tour.requires ?? []).every((requirement) => requirement.met(gates))) return false
147
+ if (pair.step.when && !pair.step.when(gates)) return false
148
+ return !navItemVisible(pair.item, gates)
149
+ }
150
+
151
+ /** Thrown by the gate proxy on the first read of a field the current branch has not assigned. */
152
+ class UnassignedGate {
153
+ constructor(readonly key: keyof NavGates) {}
154
+ }
155
+
165
156
  /**
166
- * For each pair, a gate set that OFFERS its tour while the entry its step clicks is NOT
157
+ * For one pair, a gate set that OFFERS its tour while the entry its step clicks is NOT
167
158
  * rendered, or `undefined` when no such set exists (what the guard wants). A step the gate set
168
- * DROPS (`when`) needs no anchor, so it cannot be a counterexample.
159
+ * DROPS (`when`) needs no anchor, so it cannot be a counterexample. Also returns the gate
160
+ * fields the search branched on, so the test can see that it varied something.
169
161
  *
170
162
  * Of the many counterexamples one break produces, the one reported is the SMALLEST: the gate
171
163
  * set closest to fully-permitted, so its absent fields are exactly the ones that matter. The
172
- * first witness the matrix happens to reach names most of `NavGates` and reads as noise, which
164
+ * first witness a search happens to reach names most of `NavGates` and reads as noise, which
173
165
  * is the difference between a failure that says "declare `advancedMode`" and one that says
174
166
  * "something about fourteen gates".
175
167
  *
176
- * The matrix is walked ONCE for every pair together: building each gate set per pair made the
177
- * test several times slower, enough to cross vitest's timeout on a loaded CI runner.
168
+ * The search branches lazily on the fields the pair's predicates read. It evaluates the pair
169
+ * through a proxy; the first read of a field this branch has not assigned stops the
170
+ * evaluation, and the search tries that field `false` and then `true`. A branch whose
171
+ * evaluation completes has a verdict for every gate set that agrees with its assignments, and
172
+ * its witness holds every unassigned field `true`, which keeps it as small as one can be. So
173
+ * the cost follows what one tour and one entry read, not the size of `NavGates`.
174
+ *
175
+ * This is sound only if the predicates (the tour's `requires`, the step's `when` and
176
+ * `navItemVisible`) are PURE and read each gate as a plain property (`g.field`). A read the
177
+ * proxy cannot see would let a field the verdict depends on stay held `true`, so the proxy
178
+ * refuses `in`, the key list and property descriptors loudly.
178
179
  */
179
- function navRequirementDrifts(
180
- pairs: readonly ReturnType<typeof navAnchoredSteps>[number][],
181
- ): (NavGates | undefined)[] {
182
- const smallest: (NavGates | undefined)[] = pairs.map(() => undefined)
183
- const fewestAbsent = pairs.map(() => Number.POSITIVE_INFINITY)
184
- for (const gates of everyGateSet()) {
185
- let absent: number | undefined
186
- for (const [index, pair] of pairs.entries()) {
187
- if (!(pair.tour.requires ?? []).every((requirement) => requirement.met(gates))) continue
188
- if (pair.step.when && !pair.step.when(gates)) continue
189
- if (navItemVisible(pair.item, gates)) continue
190
- absent ??= absentGates(gates).length
191
- if (absent < fewestAbsent[index]!) {
192
- fewestAbsent[index] = absent
193
- smallest[index] = gates
180
+ function navRequirementDrift(pair: ReturnType<typeof navAnchoredSteps>[number]): {
181
+ witness: NavGates | undefined
182
+ branched: ReadonlySet<keyof NavGates>
183
+ } {
184
+ const assigned: Partial<Record<keyof NavGates, boolean>> = {}
185
+ const branched = new Set<keyof NavGates>()
186
+ const refuse = (how: string) => () => {
187
+ throw new Error(`a nav predicate read the gates through ${how}; read each field directly`)
188
+ }
189
+ const gates = new Proxy(
190
+ { ...ALL_GATES },
191
+ {
192
+ get(target, key) {
193
+ if (typeof key !== 'string' || !Object.hasOwn(target, key)) return Reflect.get(target, key)
194
+ const value = assigned[key as keyof NavGates]
195
+ if (value === undefined) throw new UnassignedGate(key as keyof NavGates)
196
+ return value
197
+ },
198
+ has: refuse('`in`'),
199
+ ownKeys: refuse('the key list'),
200
+ getOwnPropertyDescriptor: refuse('a property descriptor'),
201
+ },
202
+ ) as NavGates
203
+
204
+ let witness: NavGates | undefined
205
+ let fewestAbsent = Number.POSITIVE_INFINITY
206
+ const explore = (): void => {
207
+ let verdict: boolean
208
+ try {
209
+ verdict = drifts(pair, gates)
210
+ } catch (error) {
211
+ if (!(error instanceof UnassignedGate)) throw error
212
+ branched.add(error.key)
213
+ for (const value of [false, true]) {
214
+ assigned[error.key] = value
215
+ explore()
194
216
  }
217
+ delete assigned[error.key]
218
+ return
219
+ }
220
+ if (!verdict) return
221
+ const absent = Object.values(assigned).filter((value) => value === false).length
222
+ if (absent < fewestAbsent) {
223
+ fewestAbsent = absent
224
+ witness = { ...ALL_GATES, ...assigned }
195
225
  }
196
226
  }
197
- return smallest
227
+ explore()
228
+ return { witness, branched }
198
229
  }
199
230
 
200
231
  /** Every static test id this layer actually renders. */
@@ -307,14 +338,14 @@ describe('the built-in tutorial tour catalog', () => {
307
338
  const pairs = navAnchoredSteps()
308
339
  // Guard the guard, twice over. A pairing that matched nothing would pass vacuously, and
309
340
  // there is one per tour that opens a sidebar surface: `add-service` plus every platform
310
- // tour. And a gate field that is not a boolean would silently never vary across the
311
- // matrix, leaving whatever it gates unexercised.
341
+ // tour. And a search that branched on no field would pass without varying anything:
342
+ // `navItemVisible` always reads `advancedMode` or `fullSurface`, so every pair must branch.
312
343
  expect(pairs.length).toBeGreaterThanOrEqual(8)
313
- expect(Object.values(ALL_GATES).every((value) => typeof value === 'boolean')).toBe(true)
344
+ const results = pairs.map(navRequirementDrift)
345
+ expect(results.filter((result) => result.branched.size === 0)).toEqual([])
314
346
 
315
- const witnesses = navRequirementDrifts(pairs)
316
347
  const drifted = pairs.flatMap((pair, index) => {
317
- const witness = witnesses[index]
348
+ const witness = results[index]!.witness
318
349
  if (witness === undefined) return []
319
350
  return [`${pair.label} is offered without ${absentGates(witness).join(' / ')}`]
320
351
  })
@@ -481,7 +512,7 @@ describe('tour availability across the catalog', () => {
481
512
 
482
513
  it('holds the interface tier as a requirement of the tours whose surface hides in basic', () => {
483
514
  // Two different reasons, one requirement. `share-services` clicks a nav entry marked
484
- // `advanced: true` (which `navRequirementDrifts` also enforces); `panel-reviews` clicks a
515
+ // `advanced: true` (which `navRequirementDrift` also enforces); `panel-reviews` clicks a
485
516
  // BASIC entry whose consensus section renders only in advanced mode or once a group exists,
486
517
  // one level below anything the nav guard can see. Basic is the shipped default, so without
487
518
  // this both would be offered to nearly every user and find nothing.
@@ -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()
@@ -10,6 +10,7 @@ function key(over: Partial<PublicApiKey> = {}): PublicApiKey {
10
10
  accountId: 'acc1',
11
11
  workspaceId: 'ws1',
12
12
  workspaceIds: ['ws1'],
13
+ followsMemberships: false,
13
14
  label: 'CI',
14
15
  scope: 'write',
15
16
  createdByUserId: null,
@@ -105,10 +106,20 @@ describe('publicApiKeys store', () => {
105
106
 
106
107
  const store = usePublicApiKeysStore()
107
108
  await store.create('ci', 'write')
108
- expect(body).toHaveBeenCalledWith({ label: 'ci', scope: 'write', actsAsSelf: false })
109
+ expect(body).toHaveBeenCalledWith({
110
+ label: 'ci',
111
+ scope: 'write',
112
+ actsAsSelf: false,
113
+ followsMemberships: false,
114
+ })
109
115
 
110
116
  await store.create('mine', 'write', true)
111
- expect(body).toHaveBeenCalledWith({ label: 'mine', scope: 'write', actsAsSelf: true })
117
+ expect(body).toHaveBeenCalledWith({
118
+ label: 'mine',
119
+ scope: 'write',
120
+ actsAsSelf: true,
121
+ followsMemberships: false,
122
+ })
112
123
  })
113
124
 
114
125
  it('sends a reach only when one was chosen, so the default stays this workspace alone', async () => {
@@ -119,13 +130,19 @@ describe('publicApiKeys store', () => {
119
130
 
120
131
  const store = usePublicApiKeysStore()
121
132
  await store.create('ci', 'read')
122
- expect(body).toHaveBeenLastCalledWith({ label: 'ci', scope: 'read', actsAsSelf: false })
133
+ expect(body).toHaveBeenLastCalledWith({
134
+ label: 'ci',
135
+ scope: 'read',
136
+ actsAsSelf: false,
137
+ followsMemberships: false,
138
+ })
123
139
 
124
140
  await store.create('all', 'read', false, null)
125
141
  expect(body).toHaveBeenLastCalledWith({
126
142
  label: 'all',
127
143
  scope: 'read',
128
144
  actsAsSelf: false,
145
+ followsMemberships: false,
129
146
  workspaceIds: null,
130
147
  })
131
148
 
@@ -134,8 +151,23 @@ describe('publicApiKeys store', () => {
134
151
  label: 'two',
135
152
  scope: 'read',
136
153
  actsAsSelf: false,
154
+ followsMemberships: false,
137
155
  workspaceIds: ['ws1', 'ws2'],
138
156
  })
157
+
158
+ // A key that follows its person's memberships names no workspaces, and lists here at once.
159
+ body.mockResolvedValueOnce({
160
+ key: key({ id: 'pak_follow', workspaceIds: [], followsMemberships: true }),
161
+ secret: 's',
162
+ })
163
+ await store.create('mine', 'decide', true, 'memberships')
164
+ expect(body).toHaveBeenLastCalledWith({
165
+ label: 'mine',
166
+ scope: 'decide',
167
+ actsAsSelf: true,
168
+ followsMemberships: true,
169
+ })
170
+ expect(store.keys.map((k) => k.id)).toContain('pak_follow')
139
171
  })
140
172
 
141
173
  it('revoke drops the key from the list', async () => {
@@ -59,14 +59,15 @@ export const usePublicApiKeysStore = defineStore('publicApiKeys', () => {
59
59
  * it starts can unlock them with the password sent on that call. Passed through rather than
60
60
  * defaulted here: the server writes the id off the session, so this is only ever a yes/no.
61
61
  *
62
- * `workspaceIds` is the key's reach: omitted for this workspace alone, `null` for every workspace
63
- * in the account, or a list. Anything past this workspace needs an account admin.
62
+ * `reach` is omitted for this workspace alone, `null` for every workspace in the account, a list,
63
+ * or `'memberships'` for whatever the person can act in on each call (a bound key only). A list
64
+ * or `null` needs an account admin; following memberships does not.
64
65
  */
65
66
  async function create(
66
67
  label: string,
67
68
  scope: PublicApiScope,
68
69
  actsAsSelf = false,
69
- workspaceIds?: string[] | null,
70
+ reach?: string[] | null | 'memberships',
70
71
  ): Promise<CreatedPublicApiKey> {
71
72
  const ws = useWorkspaceStore()
72
73
  const workspaceId = ws.requireId()
@@ -74,14 +75,17 @@ export const usePublicApiKeysStore = defineStore('publicApiKeys', () => {
74
75
  label,
75
76
  scope,
76
77
  actsAsSelf,
77
- ...(workspaceIds === undefined ? {} : { workspaceIds }),
78
+ followsMemberships: reach === 'memberships',
79
+ ...(reach === undefined || reach === 'memberships' ? {} : { workspaceIds: reach }),
78
80
  })
79
81
  // Prepend: the backend lists newest-first, so the freshly minted key belongs at the
80
82
  // top — matching the order a subsequent `load()` would produce. A key picked to reach only
81
83
  // other workspaces is not in this board's list, and revoking it from here would be refused as
82
84
  // a no-op, so it is not shown.
83
85
  const reaches =
84
- created.key.workspaceIds === null || created.key.workspaceIds.includes(workspaceId)
86
+ created.key.followsMemberships ||
87
+ created.key.workspaceIds === null ||
88
+ created.key.workspaceIds.includes(workspaceId)
85
89
  if (reaches) keys.value = [created.key, ...keys.value]
86
90
  available.value = true
87
91
  return created
@@ -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,
@@ -848,6 +848,7 @@
848
848
  "boundToYou": "Ihr Abonnement",
849
849
  "boundToOther": "Abonnement von {user}",
850
850
  "reachAll": "Alle Arbeitsbereiche",
851
+ "reachMemberships": "Folgt den Arbeitsbereichen seiner Person",
851
852
  "reachCount": "{count} Arbeitsbereiche"
852
853
  },
853
854
  "add": {
@@ -869,6 +870,8 @@
869
870
  "reachThis": "Nur dieser Arbeitsbereich",
870
871
  "reachAll": "Alle Arbeitsbereiche des Kontos",
871
872
  "reachSelected": "Ausgewählte Arbeitsbereiche",
873
+ "reachMemberships": "Meine Arbeitsbereiche (folgt meinen Mitgliedschaften)",
874
+ "reachMembershipsHelp": "Erreicht bei jedem Aufruf jeden Arbeitsbereich, in dem du handeln kannst: Beitritt oder Austritt ändert das, ohne neues Token. In jedem Arbeitsbereich kann es nicht mehr, als deine Rolle dort erlaubt.",
872
875
  "reachPick": "Arbeitsbereiche auswählen"
873
876
  },
874
877
  "scopes": {
@@ -3916,6 +3916,7 @@
3916
3916
  "boundToYou": "Your subscription",
3917
3917
  "boundToOther": "{user}'s subscription",
3918
3918
  "reachAll": "All workspaces",
3919
+ "reachMemberships": "Follows its owner's workspaces",
3919
3920
  "reachCount": "{count} workspaces"
3920
3921
  },
3921
3922
  "add": {
@@ -3937,6 +3938,8 @@
3937
3938
  "reachThis": "This workspace only",
3938
3939
  "reachAll": "Every workspace in the account",
3939
3940
  "reachSelected": "Selected workspaces",
3941
+ "reachMemberships": "My workspaces (follows my memberships)",
3942
+ "reachMembershipsHelp": "Reaches every workspace you can act in, on each call: joining or leaving one changes it, with nothing to re-create. In each workspace it can do no more than your role there allows.",
3940
3943
  "reachPick": "Choose workspaces"
3941
3944
  },
3942
3945
  "scopes": {
@@ -3608,6 +3608,7 @@
3608
3608
  "boundToYou": "Tu suscripción",
3609
3609
  "boundToOther": "Suscripción de {user}",
3610
3610
  "reachAll": "Todos los espacios de trabajo",
3611
+ "reachMemberships": "Sigue los espacios de trabajo de su persona",
3611
3612
  "reachCount": "{count} espacios de trabajo"
3612
3613
  },
3613
3614
  "add": {
@@ -3629,6 +3630,8 @@
3629
3630
  "reachThis": "Solo este espacio de trabajo",
3630
3631
  "reachAll": "Todos los espacios de trabajo de la cuenta",
3631
3632
  "reachSelected": "Espacios de trabajo seleccionados",
3633
+ "reachMemberships": "Mis espacios de trabajo (sigue mis membresías)",
3634
+ "reachMembershipsHelp": "Llega a todos los espacios de trabajo en los que puedes actuar, en cada llamada: unirte o salir de uno lo cambia, sin crear nada de nuevo. En cada uno no puede hacer más de lo que permite tu rol allí.",
3632
3635
  "reachPick": "Elige espacios de trabajo"
3633
3636
  },
3634
3637
  "scopes": {
@@ -3608,6 +3608,7 @@
3608
3608
  "boundToYou": "Votre abonnement",
3609
3609
  "boundToOther": "Abonnement de {user}",
3610
3610
  "reachAll": "Tous les espaces de travail",
3611
+ "reachMemberships": "Suit les espaces de travail de sa personne",
3611
3612
  "reachCount": "{count} espaces de travail"
3612
3613
  },
3613
3614
  "add": {
@@ -3629,6 +3630,8 @@
3629
3630
  "reachThis": "Cet espace de travail uniquement",
3630
3631
  "reachAll": "Tous les espaces de travail du compte",
3631
3632
  "reachSelected": "Espaces de travail sélectionnés",
3633
+ "reachMemberships": "Mes espaces de travail (suit mes adhésions)",
3634
+ "reachMembershipsHelp": "Atteint à chaque appel chaque espace de travail où vous pouvez agir : en rejoindre ou en quitter un le change, sans rien recréer. Dans chacun, il ne peut pas faire plus que votre rôle ne le permet.",
3632
3635
  "reachPick": "Choisir des espaces de travail"
3633
3636
  },
3634
3637
  "scopes": {
@@ -3749,6 +3749,7 @@
3749
3749
  "boundToYou": "המנוי שלך",
3750
3750
  "boundToOther": "המנוי של {user}",
3751
3751
  "reachAll": "כל סביבות העבודה",
3752
+ "reachMemberships": "עוקב אחרי סביבות העבודה של בעליו",
3752
3753
  "reachCount": "{count} סביבות עבודה"
3753
3754
  },
3754
3755
  "add": {
@@ -3770,6 +3771,8 @@
3770
3771
  "reachThis": "סביבת העבודה הזו בלבד",
3771
3772
  "reachAll": "כל סביבות העבודה בחשבון",
3772
3773
  "reachSelected": "סביבות עבודה נבחרות",
3774
+ "reachMemberships": "סביבות העבודה שלי (עוקב אחרי החברויות שלי)",
3775
+ "reachMembershipsHelp": "מגיע בכל קריאה לכל סביבת עבודה שבה אתה יכול לפעול: הצטרפות או עזיבה משנות זאת, בלי ליצור מחדש. בכל סביבה הוא לא יכול יותר ממה שהתפקיד שלך שם מאפשר.",
3773
3776
  "reachPick": "בחירת סביבות עבודה"
3774
3777
  },
3775
3778
  "scopes": {
@@ -848,6 +848,7 @@
848
848
  "boundToYou": "Il tuo abbonamento",
849
849
  "boundToOther": "Abbonamento di {user}",
850
850
  "reachAll": "Tutti gli spazi di lavoro",
851
+ "reachMemberships": "Segue gli spazi di lavoro della sua persona",
851
852
  "reachCount": "{count} spazi di lavoro"
852
853
  },
853
854
  "add": {
@@ -869,6 +870,8 @@
869
870
  "reachThis": "Solo questo spazio di lavoro",
870
871
  "reachAll": "Tutti gli spazi di lavoro dell’account",
871
872
  "reachSelected": "Spazi di lavoro selezionati",
873
+ "reachMemberships": "I miei spazi di lavoro (segue le mie appartenenze)",
874
+ "reachMembershipsHelp": "Raggiunge a ogni chiamata ogni spazio di lavoro in cui puoi agire: entrarne o uscirne lo cambia, senza ricreare nulla. In ciascuno non può fare più di quanto consente il tuo ruolo lì.",
872
875
  "reachPick": "Scegli gli spazi di lavoro"
873
876
  },
874
877
  "scopes": {
@@ -3749,6 +3749,7 @@
3749
3749
  "boundToYou": "あなたのサブスクリプション",
3750
3750
  "boundToOther": "{user} のサブスクリプション",
3751
3751
  "reachAll": "すべてのワークスペース",
3752
+ "reachMemberships": "所有者のワークスペースに追従",
3752
3753
  "reachCount": "{count} 個のワークスペース"
3753
3754
  },
3754
3755
  "add": {
@@ -3770,6 +3771,8 @@
3770
3771
  "reachThis": "このワークスペースのみ",
3771
3772
  "reachAll": "アカウント内のすべてのワークスペース",
3772
3773
  "reachSelected": "選択したワークスペース",
3774
+ "reachMemberships": "自分のワークスペース(メンバーシップに追従)",
3775
+ "reachMembershipsHelp": "呼び出しのたびに、あなたが操作できるすべてのワークスペースに届きます。参加や脱退で変わり、作り直しは不要です。各ワークスペースでは、そこでのあなたのロールが許す以上のことはできません。",
3773
3776
  "reachPick": "ワークスペースを選択"
3774
3777
  },
3775
3778
  "scopes": {
@@ -3608,6 +3608,7 @@
3608
3608
  "boundToYou": "Twoja subskrypcja",
3609
3609
  "boundToOther": "Subskrypcja użytkownika {user}",
3610
3610
  "reachAll": "Wszystkie obszary robocze",
3611
+ "reachMemberships": "Śledzi obszary robocze swojej osoby",
3611
3612
  "reachCount": "Obszary robocze: {count}"
3612
3613
  },
3613
3614
  "add": {
@@ -3629,6 +3630,8 @@
3629
3630
  "reachThis": "Tylko ten obszar roboczy",
3630
3631
  "reachAll": "Wszystkie obszary robocze konta",
3631
3632
  "reachSelected": "Wybrane obszary robocze",
3633
+ "reachMemberships": "Moje obszary robocze (śledzi moje członkostwa)",
3634
+ "reachMembershipsHelp": "Przy każdym wywołaniu sięga do każdego obszaru roboczego, w którym możesz działać: dołączenie lub odejście to zmienia, bez tworzenia od nowa. W każdym może tyle, ile pozwala tam Twoja rola.",
3632
3635
  "reachPick": "Wybierz obszary robocze"
3633
3636
  },
3634
3637
  "scopes": {
@@ -3749,6 +3749,7 @@
3749
3749
  "boundToYou": "Aboneliğiniz",
3750
3750
  "boundToOther": "{user} kullanıcısının aboneliği",
3751
3751
  "reachAll": "Tüm çalışma alanları",
3752
+ "reachMemberships": "Sahibinin çalışma alanlarını izler",
3752
3753
  "reachCount": "{count} çalışma alanı"
3753
3754
  },
3754
3755
  "add": {
@@ -3770,6 +3771,8 @@
3770
3771
  "reachThis": "Yalnızca bu çalışma alanı",
3771
3772
  "reachAll": "Hesaptaki tüm çalışma alanları",
3772
3773
  "reachSelected": "Seçili çalışma alanları",
3774
+ "reachMemberships": "Çalışma alanlarım (üyeliklerimi izler)",
3775
+ "reachMembershipsHelp": "Her çağrıda işlem yapabildiğiniz her çalışma alanına ulaşır: birine katılmak ya da ayrılmak bunu değiştirir, yeniden oluşturmak gerekmez. Her birinde oradaki rolünüzün izin verdiğinden fazlasını yapamaz.",
3773
3776
  "reachPick": "Çalışma alanlarını seçin"
3774
3777
  },
3775
3778
  "scopes": {
@@ -3608,6 +3608,7 @@
3608
3608
  "boundToYou": "Ваша підписка",
3609
3609
  "boundToOther": "Підписка {user}",
3610
3610
  "reachAll": "Усі робочі простори",
3611
+ "reachMemberships": "Стежить за робочими просторами свого власника",
3611
3612
  "reachCount": "Робочих просторів: {count}"
3612
3613
  },
3613
3614
  "add": {
@@ -3629,6 +3630,8 @@
3629
3630
  "reachThis": "Лише цей робочий простір",
3630
3631
  "reachAll": "Усі робочі простори облікового запису",
3631
3632
  "reachSelected": "Вибрані робочі простори",
3633
+ "reachMemberships": "Мої робочі простори (стежить за моїми членствами)",
3634
+ "reachMembershipsHelp": "Під час кожного виклику сягає кожного робочого простору, де ви можете діяти: приєднання чи вихід змінюють це без повторного створення. У кожному він може не більше, ніж дозволяє ваша роль там.",
3632
3635
  "reachPick": "Виберіть робочі простори"
3633
3636
  },
3634
3637
  "scopes": {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@cat-factory/app",
3
- "version": "0.317.2",
3
+ "version": "0.319.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",
@@ -18,7 +18,7 @@
18
18
  "access": "public"
19
19
  },
20
20
  "dependencies": {
21
- "@cat-factory/contracts": "0.372.0",
21
+ "@cat-factory/contracts": "0.373.0",
22
22
  "@modular-frontend/core": "0.6.0",
23
23
  "@modular-vue/core": "^1.5.0",
24
24
  "@modular-vue/journeys": "^1.4.0",