@cat-factory/app 0.318.0 → 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'
@@ -189,6 +217,7 @@ function applyPrefill() {
189
217
  if (prefill.label) label.value = prefill.label
190
218
  if (prefill.scope) scope.value = prefill.scope
191
219
  if (prefill.runsAsSelf && canBindSelf.value) identity.value = 'self'
220
+ if (prefill.reach === 'memberships' && identity.value === 'self') reach.value = 'memberships'
192
221
  ui.apiTokensPrefill = null
193
222
  }
194
223
 
@@ -392,18 +421,11 @@ async function revokeToken(key: PublicApiKey) {
392
421
  data-testid="api-token-identity"
393
422
  />
394
423
  </UFormField>
395
- <UFormField
396
- :label="t('settings.apiTokens.add.reach')"
397
- :help="
398
- canWidenReach
399
- ? t('settings.apiTokens.add.reachHelp')
400
- : t('settings.apiTokens.add.reachAdminOnly')
401
- "
402
- >
424
+ <UFormField :label="t('settings.apiTokens.add.reach')" :help="reachHelp">
403
425
  <USelect
404
426
  v-model="reach"
405
427
  :items="reachItems"
406
- :disabled="!canWidenReach"
428
+ :disabled="!canWidenReach && identity !== 'self'"
407
429
  class="w-full"
408
430
  data-testid="api-token-reach"
409
431
  />
@@ -13,11 +13,14 @@ describe('captureNewApiKeyDeepLink', () => {
13
13
  })
14
14
 
15
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')
16
+ setUrl(
17
+ '?newApiKey=1&label=sainte-beuve&scope=decide&runsAsSelf=1&reach=memberships&ws=ws_1&keep=yes',
18
+ )
17
19
  expect(captureNewApiKeyDeepLink()).toEqual({
18
20
  label: 'sainte-beuve',
19
21
  scope: 'decide',
20
22
  runsAsSelf: true,
23
+ reach: 'memberships',
21
24
  workspaceId: 'ws_1',
22
25
  })
23
26
  expect(window.location.search).toBe('?keep=yes')
@@ -30,11 +33,17 @@ describe('captureNewApiKeyDeepLink', () => {
30
33
  label: null,
31
34
  scope: null,
32
35
  runsAsSelf: false,
36
+ reach: null,
33
37
  workspaceId: null,
34
38
  })
35
39
  expect(window.location.search).toBe('')
36
40
  })
37
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
+
38
47
  it('drops a scope this build does not know rather than guessing one', () => {
39
48
  setUrl('?newApiKey=1&scope=superuser')
40
49
  expect(captureNewApiKeyDeepLink()).toMatchObject({ scope: null })
@@ -1,6 +1,6 @@
1
1
  /**
2
2
  * Boot-time replay of a NEW API KEY deep link:
3
- * `?newApiKey=1&label=<label>&scope=<scope>&runsAsSelf=1&ws=<id>`.
3
+ * `?newApiKey=1&label=<label>&scope=<scope>&runsAsSelf=1&reach=memberships&ws=<id>`.
4
4
  *
5
5
  * An integration that needs a person's OWN key (one bound to them, which is what lets a run use
6
6
  * their personal model subscription) cannot mint it for them: a bound key can only be minted by
@@ -23,13 +23,22 @@ export interface NewApiKeyPrefill {
23
23
  label: string | null
24
24
  scope: PublicApiScope | null
25
25
  runsAsSelf: boolean
26
+ /** `memberships`: reach whatever the person can act in. Only with `runsAsSelf`. */
27
+ reach: 'memberships' | null
26
28
  }
27
29
 
28
30
  interface PendingNewApiKeyLink extends NewApiKeyPrefill {
29
31
  workspaceId: string | null
30
32
  }
31
33
 
32
- const NEW_API_KEY_LINK_PARAMS = ['newApiKey', 'label', 'scope', 'runsAsSelf', 'ws'] as const
34
+ const NEW_API_KEY_LINK_PARAMS = [
35
+ 'newApiKey',
36
+ 'label',
37
+ 'scope',
38
+ 'runsAsSelf',
39
+ 'reach',
40
+ 'ws',
41
+ ] as const
33
42
 
34
43
  /** The form's own label limit, so a prefill never starts out invalid. */
35
44
  const LABEL_MAX_LENGTH = 120
@@ -53,6 +62,7 @@ export function captureNewApiKeyDeepLink(): PendingNewApiKeyLink | null {
53
62
  label,
54
63
  scope: scopeOf(params.get('scope')),
55
64
  runsAsSelf: params.get('runsAsSelf') === '1',
65
+ reach: params.get('reach') === 'memberships' ? 'memberships' : null,
56
66
  workspaceId: params.get('ws'),
57
67
  }
58
68
  for (const key of NEW_API_KEY_LINK_PARAMS) params.delete(key)
@@ -79,7 +89,12 @@ export function useNewApiKeyDeepLink(): void {
79
89
  (ready) => {
80
90
  if (!ready || applied) return
81
91
  applied = true
82
- ui.openApiTokens({ label: link.label, scope: link.scope, runsAsSelf: link.runsAsSelf })
92
+ ui.openApiTokens({
93
+ label: link.label,
94
+ scope: link.scope,
95
+ runsAsSelf: link.runsAsSelf,
96
+ reach: link.reach,
97
+ })
83
98
  stop?.()
84
99
  },
85
100
  { immediate: true },
@@ -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.
@@ -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
@@ -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.318.0",
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",