@cat-factory/app 0.110.0 → 0.110.2

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.
@@ -30,6 +30,7 @@ const back = useIntegrationBack(open)
30
30
  // refs in the backend MODEL_CATALOG. Only the ones present in the live browse list are
31
31
  // ticked, so a recommendation never enables a slug OpenRouter doesn't actually serve.
32
32
  const RECOMMENDED_SLUGS = [
33
+ 'anthropic/claude-fable-5',
33
34
  'anthropic/claude-opus-4.8',
34
35
  'openai/gpt-5.5',
35
36
  'google/gemini-3-pro',
@@ -219,35 +219,29 @@ export const useExecutionStore = defineStore('execution', () => {
219
219
  }
220
220
  }
221
221
 
222
- // Interacting with a running individual-usage run (resolve/approve/request-changes) rides
223
- // the CACHED personal password along transparently so the server can re-mint the run's
224
- // short-TTL activation before advancing no prompt here (the user is only re-prompted on
225
- // start/retry, once the cache lapses). For a non-individual run the server ignores it.
222
+ // Interacting with a running individual-usage run (resolve/approve/request-changes) advances
223
+ // + re-dispatches the run, so the server re-mints its short-TTL activation from the personal
224
+ // password first. It rides the cached password transparently, and like start/retry is
225
+ // gated through `withCredential`: a within-buffer/lapsed cache re-prompts EARLY here (while
226
+ // the user is present) rather than letting the run break mid-pipeline. For a non-individual
227
+ // run the server ignores it and nothing prompts.
226
228
  async function resolveDecision(instanceId: string, decisionId: string, choice: string) {
227
229
  const ws = useWorkspaceStore()
228
230
  const personal = usePersonalSubscriptionsStore()
229
- await api.resolveDecision(
230
- ws.requireId(),
231
- instanceId,
232
- decisionId,
233
- { choice },
234
- personal.getCachedPassword(),
235
- )
236
- await ws.refresh()
231
+ return await personal.withCredential(async (password) => {
232
+ await api.resolveDecision(ws.requireId(), instanceId, decisionId, { choice }, password)
233
+ await ws.refresh()
234
+ })
237
235
  }
238
236
 
239
237
  /** Approve a step's gated proposal (optionally edited); the run advances. */
240
238
  async function approveStep(instanceId: string, approvalId: string, proposal?: string) {
241
239
  const ws = useWorkspaceStore()
242
240
  const personal = usePersonalSubscriptionsStore()
243
- await api.approveStep(
244
- ws.requireId(),
245
- instanceId,
246
- approvalId,
247
- { proposal },
248
- personal.getCachedPassword(),
249
- )
250
- await ws.refresh()
241
+ return await personal.withCredential(async (password) => {
242
+ await api.approveStep(ws.requireId(), instanceId, approvalId, { proposal }, password)
243
+ await ws.refresh()
244
+ })
251
245
  }
252
246
 
253
247
  /** Request changes on a gated proposal; the step re-runs with the review. */
@@ -258,14 +252,10 @@ export const useExecutionStore = defineStore('execution', () => {
258
252
  ) {
259
253
  const ws = useWorkspaceStore()
260
254
  const personal = usePersonalSubscriptionsStore()
261
- await api.requestStepChanges(
262
- ws.requireId(),
263
- instanceId,
264
- approvalId,
265
- review,
266
- personal.getCachedPassword(),
267
- )
268
- await ws.refresh()
255
+ return await personal.withCredential(async (password) => {
256
+ await api.requestStepChanges(ws.requireId(), instanceId, approvalId, review, password)
257
+ await ws.refresh()
258
+ })
269
259
  }
270
260
 
271
261
  /** Reject a gated proposal; the run stops entirely (a retryable failure). */
@@ -278,8 +268,9 @@ export const useExecutionStore = defineStore('execution', () => {
278
268
  /**
279
269
  * Resolve a companion step parked at its rework cap: extra-round (one more pass) /
280
270
  * proceed (advance with the current output) / stop-reset (cancel + reset the task).
281
- * Rides the cached personal password so the server can re-mint the run's activation
282
- * before re-dispatching on extra-round/proceed.
271
+ * Rides the cached personal password (gated through `withCredential`, so a within-buffer
272
+ * cache re-prompts early) for the server to re-mint the run's activation before
273
+ * re-dispatching on extra-round/proceed.
283
274
  */
284
275
  async function resolveCompanionExceeded(
285
276
  instanceId: string,
@@ -288,14 +279,16 @@ export const useExecutionStore = defineStore('execution', () => {
288
279
  ) {
289
280
  const ws = useWorkspaceStore()
290
281
  const personal = usePersonalSubscriptionsStore()
291
- await api.resolveCompanionExceeded(
292
- ws.requireId(),
293
- instanceId,
294
- approvalId,
295
- { choice },
296
- personal.getCachedPassword(),
297
- )
298
- await ws.refresh()
282
+ return await personal.withCredential(async (password) => {
283
+ await api.resolveCompanionExceeded(
284
+ ws.requireId(),
285
+ instanceId,
286
+ approvalId,
287
+ { choice },
288
+ password,
289
+ )
290
+ await ws.refresh()
291
+ })
299
292
  }
300
293
 
301
294
  /** How many approval gates anywhere are awaiting a human. */
@@ -0,0 +1,112 @@
1
+ import { describe, it, expect, beforeEach, vi } from 'vitest'
2
+ import { ApiError } from '~/composables/api/errors'
3
+ import { usePersonalSubscriptionsStore } from '~/stores/personalSubscriptions'
4
+
5
+ // The single localStorage key the store caches the personal password under (private to the
6
+ // store; hard-coded here so the buffer/expiry semantics can be asserted directly).
7
+ const CACHE_KEY = 'cf.personal-pw'
8
+ const HOUR = 60 * 60 * 1000
9
+ /** Mirrors PASSWORD_EXPIRY_BUFFER_MS in the store — the runway a key must have to be ridden. */
10
+ const BUFFER_MS = 8 * HOUR
11
+
12
+ /** Write a cache entry with an explicit remaining lifetime (positive = valid, negative = past). */
13
+ function seedCache(password: string, msFromNow: number) {
14
+ localStorage.setItem(CACHE_KEY, JSON.stringify({ password, expiresAt: Date.now() + msFromNow }))
15
+ }
16
+
17
+ /** A 428 credential_required error shaped like the server envelope the store parses. */
18
+ function credentialError() {
19
+ return new ApiError(428, {
20
+ error: {
21
+ code: 'credential_required',
22
+ message: 'Enter your personal password.',
23
+ details: { vendor: 'claude', reason: 'password_required' },
24
+ },
25
+ })
26
+ }
27
+
28
+ /** Let queued microtasks (the first withCredential attempt + its catch) settle. */
29
+ async function flush() {
30
+ await new Promise((resolve) => setTimeout(resolve, 0))
31
+ }
32
+
33
+ describe('personal-password cache expiry buffer', () => {
34
+ beforeEach(() => {
35
+ localStorage.clear()
36
+ })
37
+
38
+ it('returns a healthy key both plainly and within the buffer', () => {
39
+ const store = usePersonalSubscriptionsStore()
40
+ seedCache('secret', 40 * HOUR)
41
+ expect(store.getCachedPassword()).toBe('secret')
42
+ expect(store.getCachedPassword(BUFFER_MS)).toBe('secret')
43
+ })
44
+
45
+ it('withholds a within-buffer key for a buffered read but KEEPS it in storage', () => {
46
+ const store = usePersonalSubscriptionsStore()
47
+ seedCache('secret', 2 * HOUR) // still valid, but under the 8h buffer
48
+ // Plain read still returns it (it has not truly expired)…
49
+ expect(store.getCachedPassword()).toBe('secret')
50
+ // …but a buffered read withholds it so the action re-prompts early.
51
+ expect(store.getCachedPassword(BUFFER_MS)).toBeUndefined()
52
+ // Crucially it is NOT dropped — the key is still valid, just deliberately refreshed early.
53
+ expect(localStorage.getItem(CACHE_KEY)).not.toBeNull()
54
+ })
55
+
56
+ it('removes a truly-expired key on read', () => {
57
+ const store = usePersonalSubscriptionsStore()
58
+ seedCache('stale', -HOUR)
59
+ expect(store.getCachedPassword()).toBeUndefined()
60
+ expect(localStorage.getItem(CACHE_KEY)).toBeNull()
61
+ })
62
+ })
63
+
64
+ describe('withCredential early re-entry', () => {
65
+ beforeEach(() => {
66
+ localStorage.clear()
67
+ })
68
+
69
+ it('rides a healthy cached key without prompting', async () => {
70
+ const store = usePersonalSubscriptionsStore()
71
+ seedCache('secret', 40 * HOUR)
72
+ // The action only fails when it receives no password; a healthy key is passed through.
73
+ const action = vi.fn(async (pw?: string) => {
74
+ if (!pw) throw credentialError()
75
+ })
76
+ const ran = await store.withCredential(action)
77
+ expect(ran).toBe(true)
78
+ expect(action).toHaveBeenCalledWith('secret')
79
+ expect(store.pending).toBeNull()
80
+ })
81
+
82
+ it('prompts EARLY when the cached key is within the buffer, then refreshes it on re-entry', async () => {
83
+ const store = usePersonalSubscriptionsStore()
84
+ seedCache('secret', 2 * HOUR) // valid but within the 8h buffer → withheld on the first try
85
+ const action = vi.fn(async (pw?: string) => {
86
+ if (!pw) throw credentialError()
87
+ })
88
+
89
+ const done = store.withCredential(action)
90
+ await flush()
91
+
92
+ // The buffered first attempt sent no password → the server 428'd → the modal opened,
93
+ // even though the key had NOT actually expired yet. That is the early re-entry.
94
+ expect(action).toHaveBeenCalledWith(undefined)
95
+ expect(store.pending).not.toBeNull()
96
+ expect(store.pending?.reason).toBe('password_required')
97
+
98
+ const pending = store.pending!
99
+ await pending.retry('fresh-secret')
100
+
101
+ await expect(done).resolves.toBe(true)
102
+ expect(action).toHaveBeenLastCalledWith('fresh-secret')
103
+ expect(store.pending).toBeNull()
104
+ // The re-entered password is cached with a full (well beyond the buffer) window.
105
+ const cached = JSON.parse(localStorage.getItem(CACHE_KEY)!) as {
106
+ password: string
107
+ expiresAt: number
108
+ }
109
+ expect(cached.password).toBe('fresh-secret')
110
+ expect(cached.expiresAt - Date.now()).toBeGreaterThan(BUFFER_MS)
111
+ })
112
+ })
@@ -19,6 +19,13 @@ import type {
19
19
  // sent as the `X-Personal-Password` header — and the user is only re-prompted once it
20
20
  // expires (or is wrong).
21
21
  //
22
+ // Every gated action (start / retry / confirm) re-validates the cache against an 8h EXPIRY
23
+ // BUFFER: a key with less than that runway left is withheld (treated as absent) so the
24
+ // server's 428 gate re-challenges EARLY and the modal refreshes the full window. This is
25
+ // what keeps a key from lapsing MID-PIPELINE — the run breaks with a retry only if the key
26
+ // was allowed to run down while the user wasn't looking, so we ask for re-entry while they
27
+ // still are (at the start/confirm/retry they just triggered), not later.
28
+ //
22
29
  // Caching here is a DELIBERATE convenience choice, not a security weakness. The password
23
30
  // layer exists to prevent ACCIDENTAL misuse (a credential can't be silently pooled); the
24
31
  // real at-rest protection is the server's system encryption, which the cache doesn't touch.
@@ -30,6 +37,12 @@ import type {
30
37
 
31
38
  /** How long a typed password stays cached before the user is re-prompted (40h). */
32
39
  const PASSWORD_TTL_MS = 40 * 60 * 60 * 1000
40
+ /**
41
+ * Runway a cached password must still have for a gated action to ride it. Within this
42
+ * window of expiry the key is withheld so the action re-challenges and refreshes the cache
43
+ * EARLY — a buffer wide enough that a pipeline kicked off now won't outlive the key (8h).
44
+ */
45
+ const PASSWORD_EXPIRY_BUFFER_MS = 8 * 60 * 60 * 1000
33
46
  const CACHE_KEY = 'cf.personal-pw'
34
47
 
35
48
  /** A credential prompt the UI must satisfy (set when the server replies 428). */
@@ -98,7 +111,13 @@ export const usePersonalSubscriptionsStore = defineStore('personalSubscriptions'
98
111
  const renewals = computed(() => subscriptions.value.filter((s) => s.renewSoon))
99
112
 
100
113
  // --- client-side password cache (single localStorage key + TTL) ------------
101
- function getCachedPassword(): string | undefined {
114
+ /**
115
+ * The cached password, or `undefined` when there is none to ride. `bufferMs` withholds a
116
+ * still-valid key that is within that window of expiry (WITHOUT dropping it) so a gated
117
+ * action re-challenges early; a truly-expired key is always removed. Default `0` = the
118
+ * plain "is it still valid right now" read.
119
+ */
120
+ function getCachedPassword(bufferMs = 0): string | undefined {
102
121
  if (typeof localStorage === 'undefined') return undefined
103
122
  try {
104
123
  const raw = localStorage.getItem(CACHE_KEY)
@@ -108,6 +127,9 @@ export const usePersonalSubscriptionsStore = defineStore('personalSubscriptions'
108
127
  localStorage.removeItem(CACHE_KEY)
109
128
  return undefined
110
129
  }
130
+ // Within the buffer the key is still valid — keep it, but withhold it so the action
131
+ // re-challenges and refreshes the full window before it can lapse mid-pipeline.
132
+ if (Date.now() + bufferMs > expiresAt) return undefined
111
133
  return password
112
134
  } catch {
113
135
  return undefined
@@ -147,9 +169,11 @@ export const usePersonalSubscriptionsStore = defineStore('personalSubscriptions'
147
169
  */
148
170
  async function withCredential(action: (password?: string) => Promise<void>): Promise<boolean> {
149
171
  // First attempt may carry no password (non-individual runs) or the single cached one;
150
- // the server only consults it when the block needs it.
172
+ // the server only consults it when the block needs it. A key within the expiry buffer
173
+ // is withheld so an individual-usage action 428s and re-prompts EARLY (refreshing the
174
+ // window) rather than riding a key that could lapse mid-pipeline.
151
175
  try {
152
- await action(getCachedPassword())
176
+ await action(getCachedPassword(PASSWORD_EXPIRY_BUFFER_MS))
153
177
  return true
154
178
  } catch (error) {
155
179
  const credential = parseCredentialError(error)
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@cat-factory/app",
3
- "version": "0.110.0",
3
+ "version": "0.110.2",
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",