From 89a5de9656e9fe20057063527ca8822565859747 Mon Sep 17 00:00:00 2001 From: plind-junior <59729252+plind-junior@users.noreply.github.com> Date: Sat, 25 Jul 2026 00:24:48 +0900 Subject: [PATCH] fix(webapp): unwrap the {items} envelope for kb.list_* results MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit kb.list_* moved from a bare array to a `{items, _meta}` dict envelope (server deprecation, remove_in 1.4.0), but the webapp still typed and consumed these as flat arrays — so `r.data.map` in Shell and PendingView threw "map is not a function", crashing the whole tree through the error boundary on every page, the pending queue included. unwrap `items` once in rpc(), scoped to kb.list_* methods, tolerating the old bare-array shape and leaving kb.list_sessions ({sessions}) and non-list object results untouched. one central change fixes the pending page and every list view; the fan-out, optimistic caches, and views are unaffected. --- webapp/src/lib/rpc.test.ts | 24 ++++++++++++++++++++++++ webapp/src/lib/rpc.ts | 23 ++++++++++++++++++++++- 2 files changed, 46 insertions(+), 1 deletion(-) diff --git a/webapp/src/lib/rpc.test.ts b/webapp/src/lib/rpc.test.ts index 07c59112..9d96cfa8 100644 --- a/webapp/src/lib/rpc.test.ts +++ b/webapp/src/lib/rpc.test.ts @@ -29,6 +29,30 @@ test('rpc posts the envelope to /proxy/rpc with target + bearer headers and unwr expect(sent.id).toMatch(/^ui-\d+$/) }) +test('rpc unwraps the {items} envelope for kb.list_* results', async () => { + mockFetch(200, { id: 'x', ok: true, result: { items: [{ id: 'a' }, { id: 'b' }], _meta: { deprecation: {} } } }) + const out = await rpc<{ id: string }[]>(conn, 'kb.list_pending') + expect(out).toEqual([{ id: 'a' }, { id: 'b' }]) +}) + +test('rpc passes a bare-array kb.list_* result through unchanged (old server)', async () => { + mockFetch(200, { id: 'x', ok: true, result: [{ id: 'a' }] }) + const out = await rpc<{ id: string }[]>(conn, 'kb.list_claims') + expect(out).toEqual([{ id: 'a' }]) +}) + +test('rpc leaves kb.list_sessions {sessions} envelope untouched', async () => { + mockFetch(200, { id: 'x', ok: true, result: { sessions: [{ session_id: 's1' }] } }) + const out = await rpc<{ sessions: { session_id: string }[] }>(conn, 'kb.list_sessions') + expect(out).toEqual({ sessions: [{ session_id: 's1' }] }) +}) + +test('rpc does not unwrap items for non-list methods', async () => { + mockFetch(200, { id: 'x', ok: true, result: { items: [1, 2, 3] } }) + const out = await rpc<{ items: number[] }>(conn, 'kb.synthesize') + expect(out).toEqual({ items: [1, 2, 3] }) +}) + test('rpc omits authorization header when no token', async () => { const fn = mockFetch(200, { id: 'x', ok: true, result: {} }) await rpc({ endpoint: 'http://127.0.0.1:8731' }, 'kb.status') diff --git a/webapp/src/lib/rpc.ts b/webapp/src/lib/rpc.ts index c3ee7d00..fb73f731 100644 --- a/webapp/src/lib/rpc.ts +++ b/webapp/src/lib/rpc.ts @@ -44,7 +44,28 @@ export async function rpc( if (!body.ok || body.error) { throw new VouchRpcError(body.error?.code ?? 'unknown', body.error?.message ?? 'unknown error') } - return body.result as T + return unwrapListEnvelope(method, body.result) as T +} + +/** + * `kb.list_*` results moved from a bare array to a `{ items, _meta }` dict + * envelope (server deprecation, remove_in 1.4.0). Consumers still type these + * as flat arrays, so unwrap `items` here and keep tolerating the old shape — + * one place, so the fan-out, optimistic caches, and views are untouched. + * `kb.list_sessions` keeps its own `{ sessions }` key and has no `items`, so it + * passes through unchanged. + */ +function unwrapListEnvelope(method: string, result: T): T { + if ( + method.startsWith('kb.list_') && + result !== null && + typeof result === 'object' && + !Array.isArray(result) && + Array.isArray((result as { items?: unknown }).items) + ) { + return (result as unknown as { items: T }).items + } + return result } export async function fetchHealth(conn: VouchConnectionInfo): Promise {