@cat-factory/app 0.37.1 → 0.37.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.
@@ -1,5 +1,6 @@
1
1
  <script setup lang="ts">
2
2
  import { computed, ref } from 'vue'
3
+ import { apiErrorEnvelope } from '~/composables/api/errors'
3
4
 
4
5
  const auth = useAuthStore()
5
6
 
@@ -37,8 +38,7 @@ async function submitPassword() {
37
38
  if (typeof window !== 'undefined') window.location.assign(window.location.pathname)
38
39
  } catch (e) {
39
40
  error.value =
40
- (e as { data?: { error?: { message?: string } } })?.data?.error?.message ??
41
- 'Sign-in failed. Check your details and try again.'
41
+ apiErrorEnvelope(e)?.message ?? 'Sign-in failed. Check your details and try again.'
42
42
  } finally {
43
43
  busy.value = false
44
44
  }
@@ -1,5 +1,6 @@
1
1
  <script setup lang="ts">
2
2
  import { computed, onMounted, ref, watch } from 'vue'
3
+ import { apiErrorEnvelope } from '~/composables/api/errors'
3
4
  import type { AccountRole } from '~/types/domain'
4
5
  import AccountDeploymentSettings from '~/components/layout/AccountDeploymentSettings.vue'
5
6
 
@@ -39,9 +40,7 @@ async function updateMemberRoles(userId: string, roles: AccountRole[]) {
39
40
  function notifyError(title: string, e: unknown) {
40
41
  toast.add({
41
42
  title,
42
- description:
43
- (e as { data?: { error?: { message?: string } } })?.data?.error?.message ??
44
- (e instanceof Error ? e.message : String(e)),
43
+ description: apiErrorEnvelope(e)?.message ?? (e instanceof Error ? e.message : String(e)),
45
44
  icon: 'i-lucide-triangle-alert',
46
45
  color: 'error',
47
46
  })
@@ -11,6 +11,7 @@ import {
11
11
  type WretchInstance,
12
12
  } from '@toad-contracts/frontend-http-client'
13
13
  import wretch from 'wretch'
14
+ import { ApiError } from './errors'
14
15
 
15
16
  /**
16
17
  * The validated success-response body inferred from a route contract (every REST
@@ -75,7 +76,16 @@ export async function sendContract<T extends ApiContract>(
75
76
  params: SendParams<T>,
76
77
  ): Promise<SuccessBodyOf<T>> {
77
78
  const outcome = await sendByApiContract(client, contract, params)
78
- if (outcome.error) throw outcome.error
79
+ if (outcome.error) {
80
+ const error = outcome.error
81
+ // A contract-declared non-2xx is reported as a plain `{ statusCode, headers, body }`
82
+ // value (not an Error). Wrap it so call sites get `instanceof Error` + the server's
83
+ // message; anything already an Error (UnexpectedResponseError, request-validation
84
+ // SchemaValidationError, a network fault) is rethrown unchanged.
85
+ if (error instanceof Error) throw error
86
+ const { statusCode, body } = error as { statusCode: number; body: unknown }
87
+ throw new ApiError(statusCode, body)
88
+ }
79
89
  return outcome.result!.body as SuccessBodyOf<T>
80
90
  }
81
91
 
@@ -0,0 +1,53 @@
1
+ import { describe, expect, it } from 'vitest'
2
+ import { ApiError, apiErrorEnvelope, apiErrorStatus } from '~/composables/api/errors'
3
+
4
+ // The contract client (`sendByApiContract`) reports a declared non-2xx as a plain
5
+ // `{ statusCode, headers, body }` value — body under `.body`, NOT an Error. Before the
6
+ // `ApiError` wrap, every `instanceof Error` check rendered "[object Object]" and every
7
+ // `.data.error` reader (parseConflict / parseCredentialError / login + probe messages)
8
+ // silently returned nothing. These tests lock that in.
9
+
10
+ describe('ApiError', () => {
11
+ const body = { error: { code: 'conflict', message: 'Nope', details: { reason: 'task_limit' } } }
12
+
13
+ it('is a real Error carrying the server message, status, and body', () => {
14
+ const e = new ApiError(409, body)
15
+ expect(e).toBeInstanceOf(Error)
16
+ expect(e.message).toBe('Nope') // not "[object Object]"
17
+ expect(e.statusCode).toBe(409)
18
+ expect(e.envelope).toEqual(body.error)
19
+ })
20
+
21
+ it('falls back to a status message when the body carries no envelope', () => {
22
+ expect(new ApiError(500, 'gateway down').message).toBe('Request failed (HTTP 500)')
23
+ })
24
+ })
25
+
26
+ describe('apiErrorEnvelope', () => {
27
+ const envelope = { code: 'credential_required', message: 'Unlock', details: { vendor: 'claude' } }
28
+
29
+ it('reads the envelope from a wrapped ApiError (contract client)', () => {
30
+ expect(apiErrorEnvelope(new ApiError(428, { error: envelope }))).toEqual(envelope)
31
+ })
32
+
33
+ it('reads the envelope from a bare { body } value (contract client, unwrapped)', () => {
34
+ expect(apiErrorEnvelope({ statusCode: 428, body: { error: envelope } })).toEqual(envelope)
35
+ })
36
+
37
+ it('reads the envelope from a legacy $fetch FetchError (body under .data)', () => {
38
+ expect(apiErrorEnvelope({ statusCode: 428, data: { error: envelope } })).toEqual(envelope)
39
+ })
40
+
41
+ it('returns undefined for a network/non-API error', () => {
42
+ expect(apiErrorEnvelope(new Error('socket hang up'))).toBeUndefined()
43
+ expect(apiErrorEnvelope(undefined)).toBeUndefined()
44
+ })
45
+ })
46
+
47
+ describe('apiErrorStatus', () => {
48
+ it('reads .statusCode (contract client) and .status (legacy)', () => {
49
+ expect(apiErrorStatus(new ApiError(503, {}))).toBe(503)
50
+ expect(apiErrorStatus({ status: 500 })).toBe(500)
51
+ expect(apiErrorStatus(new Error('x'))).toBeUndefined()
52
+ })
53
+ })
@@ -0,0 +1,63 @@
1
+ /**
2
+ * A failed API call, normalised to a real `Error`.
3
+ *
4
+ * The contract client (`sendByApiContract`) reports a contract-declared non-2xx as a
5
+ * plain `{ statusCode, headers, body }` value — NOT an `Error` — with the parsed
6
+ * `{ error: { code, message, details } }` envelope under `body`. Throwing that bare
7
+ * object breaks every `error instanceof Error` check (they fall to `String(error)` =
8
+ * `"[object Object]"`) and hides the server's message. `sendContract` wraps it in this
9
+ * class so call sites get `instanceof Error`, a real `.message` (the server's), the
10
+ * `.statusCode`, and the typed `.envelope`.
11
+ */
12
+ export class ApiError extends Error {
13
+ readonly statusCode: number
14
+ /** The parsed response body (the `{ error: {...} }` envelope for our controllers). */
15
+ readonly body: unknown
16
+
17
+ constructor(statusCode: number, body: unknown) {
18
+ super(envelopeOf(body)?.message ?? `Request failed (HTTP ${statusCode})`)
19
+ this.name = 'ApiError'
20
+ this.statusCode = statusCode
21
+ this.body = body
22
+ }
23
+
24
+ /** The `{ code, message, details, issues }` envelope, when the body carries one. */
25
+ get envelope(): ApiErrorEnvelope | undefined {
26
+ return envelopeOf(this.body)
27
+ }
28
+ }
29
+
30
+ /** The error envelope every controller emits (`handleError` / contract request-validator). */
31
+ export interface ApiErrorEnvelope {
32
+ code?: string
33
+ message?: string
34
+ details?: unknown
35
+ issues?: { path?: string; message: string }[]
36
+ }
37
+
38
+ /** Read the `{ error: {...} }` envelope out of a parsed response body, else undefined. */
39
+ function envelopeOf(body: unknown): ApiErrorEnvelope | undefined {
40
+ if (!body || typeof body !== 'object') return undefined
41
+ const error = (body as { error?: unknown }).error
42
+ return error && typeof error === 'object' ? (error as ApiErrorEnvelope) : undefined
43
+ }
44
+
45
+ /**
46
+ * Pull the server error envelope out of any thrown API error, regardless of which client
47
+ * produced it: the contract client (`ApiError`, body under `.body`) or the legacy `$fetch`
48
+ * path (ofetch `FetchError`, body under `.data`). Returns undefined for network faults or
49
+ * non-API errors.
50
+ */
51
+ export function apiErrorEnvelope(error: unknown): ApiErrorEnvelope | undefined {
52
+ if (error instanceof ApiError) return error.envelope
53
+ const e = error as { body?: unknown; data?: unknown }
54
+ return envelopeOf(e?.body) ?? envelopeOf(e?.data)
55
+ }
56
+
57
+ /** The HTTP status of a thrown API error, when present (contract client or `$fetch`). */
58
+ export function apiErrorStatus(error: unknown): number | undefined {
59
+ const e = error as { statusCode?: unknown; status?: unknown }
60
+ if (typeof e?.statusCode === 'number') return e.statusCode
61
+ if (typeof e?.status === 'number') return e.status
62
+ return undefined
63
+ }
@@ -6,6 +6,8 @@
6
6
  * SAME guidance + "Configure AI" jump as the no-AI-provider startup banner.
7
7
  */
8
8
 
9
+ import { apiErrorEnvelope } from './api/errors'
10
+
9
11
  /** The parsed shape of a backend conflict (`{ error: { code: 'conflict', details } }`). */
10
12
  interface ConflictDetails {
11
13
  reason?: string
@@ -13,15 +15,13 @@ interface ConflictDetails {
13
15
  [key: string]: unknown
14
16
  }
15
17
 
16
- /** Pull a 409 conflict's `{ reason, message, details }` out of a thrown fetch error, else null. */
18
+ /** Pull a 409 conflict's `{ reason, message, details }` out of a thrown API error, else null. */
17
19
  export function parseConflict(
18
20
  error: unknown,
19
21
  ): { reason?: string; message: string; details: ConflictDetails } | null {
20
- const body = (
21
- error as { data?: { error?: { code?: string; message?: string; details?: ConflictDetails } } }
22
- )?.data?.error
22
+ const body = apiErrorEnvelope(error)
23
23
  if (body?.code !== 'conflict') return null
24
- const details = body.details ?? {}
24
+ const details = (body.details as ConflictDetails | undefined) ?? {}
25
25
  return {
26
26
  reason: typeof details.reason === 'string' ? details.reason : undefined,
27
27
  message: body.message ?? 'This action conflicts with the current state.',
@@ -1,4 +1,5 @@
1
1
  import { type ComputedRef, type Ref, computed, ref } from 'vue'
2
+ import { apiErrorEnvelope, apiErrorStatus } from '~/composables/api/errors'
2
3
  import { useUpsertList } from '~/composables/useUpsertList'
3
4
 
4
5
  /**
@@ -79,10 +80,9 @@ export function useSourceIntegration<
79
80
  // reason so a panel can explain it (503 = off here; 500 = the backend errored, e.g. an
80
81
  // unapplied migration).
81
82
  available.value = false
82
- const err = e as { statusCode?: number; data?: { error?: { message?: string } } }
83
- const serverMessage = err?.data?.error?.message
83
+ const serverMessage = apiErrorEnvelope(e)?.message
84
84
  probeError.value = {
85
- status: err?.statusCode ?? null,
85
+ status: apiErrorStatus(e) ?? null,
86
86
  message: serverMessage || (e instanceof Error ? e.message : String(e)),
87
87
  }
88
88
  sources.value = []
@@ -1,5 +1,6 @@
1
1
  import { defineStore } from 'pinia'
2
2
  import { computed, ref } from 'vue'
3
+ import { apiErrorEnvelope } from '~/composables/api/errors'
3
4
  import type {
4
5
  PersonalSubscriptionStatus,
5
6
  StorePersonalSubscriptionInput,
@@ -45,7 +46,7 @@ export interface PendingCredential {
45
46
  export function parseCredentialError(
46
47
  error: unknown,
47
48
  ): { vendor: SubscriptionVendor; reason: PendingCredential['reason'] } | null {
48
- const data = (error as { data?: { error?: { code?: string; details?: unknown } } })?.data?.error
49
+ const data = apiErrorEnvelope(error)
49
50
  if (data?.code !== 'credential_required') return null
50
51
  const details = data.details as {
51
52
  vendor?: SubscriptionVendor
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@cat-factory/app",
3
- "version": "0.37.1",
3
+ "version": "0.37.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",
@@ -32,7 +32,7 @@
32
32
  "pinia-plugin-persistedstate": "^4.7.1",
33
33
  "vue": "^3.5.38",
34
34
  "wretch": "^3.0.9",
35
- "@cat-factory/contracts": "0.35.0"
35
+ "@cat-factory/contracts": "0.36.0"
36
36
  },
37
37
  "devDependencies": {
38
38
  "@toad-contracts/testing": "0.3.1",