Skip to content

Commit 55de588

Browse files
Vu-Johnclaude
andauthored
PR-I2: useXaaResourceApps hook + resource-app types (#2658)
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
1 parent 67d961e commit 55de588

3 files changed

Lines changed: 380 additions & 0 deletions

File tree

Lines changed: 183 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,183 @@
1+
import { describe, expect, it, vi, beforeEach } from "vitest";
2+
import { renderHook, act } from "@testing-library/react";
3+
4+
// ── Mocks ──────────────────────────────────────────────────────────────
5+
let convexAuth = { isAuthenticated: true, isLoading: false };
6+
let queryReturn: unknown = undefined;
7+
let hostedMode = true;
8+
const upsertAction = vi.fn(async () => ({ id: "app_new" }));
9+
const removeAction = vi.fn(async () => undefined);
10+
let lastQueryArgs: unknown;
11+
12+
vi.mock("convex/react", () => ({
13+
useConvexAuth: () => convexAuth,
14+
useQuery: (_name: unknown, args: unknown) => {
15+
lastQueryArgs = args;
16+
return args === "skip" ? undefined : queryReturn;
17+
},
18+
useAction: (name: unknown) =>
19+
name === "xaaResourceApps:upsert" ? upsertAction : removeAction,
20+
}));
21+
22+
vi.mock("@/lib/config", () => ({
23+
get HOSTED_MODE() {
24+
return hostedMode;
25+
},
26+
}));
27+
28+
// Import AFTER mocks are set up.
29+
import { useXaaResourceApps } from "../useXaaResourceApps";
30+
31+
const ORG_ID = "org_test";
32+
33+
const VALID_ROW = {
34+
id: "app_1",
35+
name: "My Resource",
36+
resourceType: "mcp",
37+
resourceUrl: "https://resource.example.com/mcp",
38+
authServerMode: "own",
39+
tokenEndpoint: "https://as.example.com/oauth/token",
40+
hasSecret: true,
41+
createdAt: 100,
42+
updatedAt: 200,
43+
};
44+
45+
describe("useXaaResourceApps", () => {
46+
beforeEach(() => {
47+
convexAuth = { isAuthenticated: true, isLoading: false };
48+
queryReturn = undefined;
49+
hostedMode = true;
50+
lastQueryArgs = undefined;
51+
upsertAction.mockClear();
52+
removeAction.mockClear();
53+
});
54+
55+
describe("auth + hosted gate", () => {
56+
it("fetches and reports isAuthenticated when authenticated, org-scoped, hosted", () => {
57+
queryReturn = { resourceApps: [VALID_ROW] };
58+
const { result } = renderHook(() => useXaaResourceApps(ORG_ID));
59+
expect(result.current.isAuthenticated).toBe(true);
60+
expect(lastQueryArgs).toEqual({ organizationId: ORG_ID });
61+
expect(result.current.resourceApps).toHaveLength(1);
62+
});
63+
64+
it("skips the query and reports isAuthenticated=false when not hosted", () => {
65+
hostedMode = false;
66+
const { result } = renderHook(() => useXaaResourceApps(ORG_ID));
67+
expect(result.current.isAuthenticated).toBe(false);
68+
expect(lastQueryArgs).toBe("skip");
69+
expect(result.current.isLoading).toBe(false);
70+
});
71+
72+
it("skips when unauthenticated", () => {
73+
convexAuth = { isAuthenticated: false, isLoading: false };
74+
const { result } = renderHook(() => useXaaResourceApps(ORG_ID));
75+
expect(result.current.isAuthenticated).toBe(false);
76+
expect(lastQueryArgs).toBe("skip");
77+
});
78+
79+
it("skips when no organization is selected", () => {
80+
const { result } = renderHook(() => useXaaResourceApps(null));
81+
expect(result.current.isAuthenticated).toBe(false);
82+
expect(lastQueryArgs).toBe("skip");
83+
});
84+
85+
it("reports isLoading while the gated query is in flight", () => {
86+
queryReturn = undefined; // query returns undefined => still loading
87+
const { result } = renderHook(() => useXaaResourceApps(ORG_ID));
88+
expect(result.current.isLoading).toBe(true);
89+
});
90+
91+
it("reports isLoading during the auth-bootstrap window (hosted)", () => {
92+
convexAuth = { isAuthenticated: false, isLoading: true };
93+
const { result } = renderHook(() => useXaaResourceApps(ORG_ID));
94+
expect(result.current.isLoading).toBe(true);
95+
expect(result.current.isAuthenticated).toBe(false);
96+
});
97+
});
98+
99+
describe("normalize", () => {
100+
it("accepts a bare array as well as a wrapped envelope", () => {
101+
queryReturn = [VALID_ROW];
102+
const { result } = renderHook(() => useXaaResourceApps(ORG_ID));
103+
expect(result.current.resourceApps).toHaveLength(1);
104+
expect(result.current.resourceApps[0]).toMatchObject({
105+
id: "app_1",
106+
resourceType: "mcp",
107+
authServerMode: "own",
108+
hasSecret: true,
109+
});
110+
});
111+
112+
it("drops malformed rows (bad enum / missing id)", () => {
113+
queryReturn = {
114+
resourceApps: [
115+
VALID_ROW,
116+
{ ...VALID_ROW, id: "app_2", resourceType: "grpc" },
117+
{ ...VALID_ROW, id: undefined },
118+
],
119+
};
120+
const { result } = renderHook(() => useXaaResourceApps(ORG_ID));
121+
expect(result.current.resourceApps).toHaveLength(1);
122+
expect(result.current.resourceApps[0].id).toBe("app_1");
123+
});
124+
125+
it("never surfaces a secret value even if the wire carried one", () => {
126+
queryReturn = {
127+
resourceApps: [{ ...VALID_ROW, secret: "leaked", vaultObjectId: "v" }],
128+
};
129+
const { result } = renderHook(() => useXaaResourceApps(ORG_ID));
130+
const app = result.current.resourceApps[0] as Record<string, unknown>;
131+
expect(app).not.toHaveProperty("secret");
132+
expect(app).not.toHaveProperty("vaultObjectId");
133+
});
134+
});
135+
136+
describe("mutations", () => {
137+
it("upsert forwards organizationId and the input", async () => {
138+
const { result } = renderHook(() => useXaaResourceApps(ORG_ID));
139+
await act(async () => {
140+
await result.current.upsert({
141+
name: "New",
142+
resourceType: "rest",
143+
resourceUrl: "https://r.example.com/api",
144+
authServerMode: "mcpjam",
145+
});
146+
});
147+
expect(upsertAction).toHaveBeenCalledWith(
148+
expect.objectContaining({
149+
organizationId: ORG_ID,
150+
name: "New",
151+
authServerMode: "mcpjam",
152+
}),
153+
);
154+
});
155+
156+
it("remove forwards id + organizationId", async () => {
157+
const { result } = renderHook(() => useXaaResourceApps(ORG_ID));
158+
await act(async () => {
159+
await result.current.remove("app_1");
160+
});
161+
expect(removeAction).toHaveBeenCalledWith({
162+
id: "app_1",
163+
organizationId: ORG_ID,
164+
});
165+
});
166+
167+
it("captures the error message when a mutation throws", async () => {
168+
upsertAction.mockRejectedValueOnce(new Error("boom"));
169+
const { result } = renderHook(() => useXaaResourceApps(ORG_ID));
170+
await act(async () => {
171+
await expect(
172+
result.current.upsert({
173+
name: "x",
174+
resourceType: "rest",
175+
resourceUrl: "https://r.example.com/api",
176+
authServerMode: "mcpjam",
177+
}),
178+
).rejects.toThrow("boom");
179+
});
180+
expect(result.current.error).toBe("boom");
181+
});
182+
});
183+
});
Lines changed: 156 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,156 @@
1+
import { useAction, useConvexAuth, useQuery } from "convex/react";
2+
import { useCallback, useMemo, useState } from "react";
3+
import { HOSTED_MODE } from "@/lib/config";
4+
import type {
5+
XaaAuthServerMode,
6+
XaaResourceApp,
7+
XaaResourceAppInput,
8+
XaaResourceType,
9+
} from "@/lib/xaa/types";
10+
11+
const RESOURCE_TYPES: ReadonlySet<XaaResourceType> = new Set(["rest", "mcp"]);
12+
const AUTH_SERVER_MODES: ReadonlySet<XaaAuthServerMode> = new Set([
13+
"mcpjam",
14+
"own",
15+
]);
16+
17+
function optionalString(value: unknown): string | undefined {
18+
return typeof value === "string" && value.length > 0 ? value : undefined;
19+
}
20+
21+
function optionalStringArray(value: unknown): string[] | undefined {
22+
if (!Array.isArray(value)) return undefined;
23+
const strings = value.filter((v): v is string => typeof v === "string");
24+
return strings.length > 0 ? strings : undefined;
25+
}
26+
27+
// The backend already sanitizes (no vaultObjectId/secret ever leaves the
28+
// wire); this normalizer just coerces the loose `as any` query result into the
29+
// typed shape and drops anything malformed.
30+
function normalizeResourceApp(raw: unknown): XaaResourceApp | null {
31+
if (!raw || typeof raw !== "object") return null;
32+
const r = raw as Record<string, unknown>;
33+
if (typeof r.id !== "string" || typeof r.name !== "string") return null;
34+
if (!RESOURCE_TYPES.has(r.resourceType as XaaResourceType)) return null;
35+
if (!AUTH_SERVER_MODES.has(r.authServerMode as XaaAuthServerMode))
36+
return null;
37+
38+
return {
39+
id: r.id,
40+
name: r.name,
41+
resourceType: r.resourceType as XaaResourceType,
42+
resourceUrl: typeof r.resourceUrl === "string" ? r.resourceUrl : "",
43+
authServerMode: r.authServerMode as XaaAuthServerMode,
44+
tokenEndpoint: optionalString(r.tokenEndpoint),
45+
issuer: optionalString(r.issuer),
46+
targetClientId: optionalString(r.targetClientId),
47+
scopes: optionalStringArray(r.scopes),
48+
healthCheckUrl: optionalString(r.healthCheckUrl),
49+
hasSecret: r.hasSecret === true,
50+
createdAt: typeof r.createdAt === "number" ? r.createdAt : 0,
51+
updatedAt: typeof r.updatedAt === "number" ? r.updatedAt : 0,
52+
};
53+
}
54+
55+
function normalizeList(raw: unknown): XaaResourceApp[] {
56+
// Accept either a wrapped `{ resourceApps: [...] }` or a bare array, so a
57+
// future shape tweak on the backend doesn't break the consumer.
58+
const list = Array.isArray(raw)
59+
? raw
60+
: raw && typeof raw === "object"
61+
? (raw as { resourceApps?: unknown }).resourceApps
62+
: undefined;
63+
if (!Array.isArray(list)) return [];
64+
return list
65+
.map(normalizeResourceApp)
66+
.filter((app): app is XaaResourceApp => app !== null);
67+
}
68+
69+
export interface UseXaaResourceAppsResult {
70+
resourceApps: XaaResourceApp[];
71+
isLoading: boolean;
72+
/**
73+
* The full gate the hook uses internally to fetch — registration is a hosted
74+
* feature, so consumers get `true` only when authenticated, scoped to an
75+
* org, AND in hosted mode. Never a half-gate.
76+
*/
77+
isAuthenticated: boolean;
78+
error: string | null;
79+
upsert: (input: XaaResourceAppInput) => Promise<{ id: string }>;
80+
remove: (id: string) => Promise<void>;
81+
}
82+
83+
export function useXaaResourceApps(
84+
organizationId: string | null,
85+
): UseXaaResourceAppsResult {
86+
const { isAuthenticated: hasConvexIdentity, isLoading: isAuthLoading } =
87+
useConvexAuth();
88+
89+
const enabled = hasConvexIdentity && !!organizationId && HOSTED_MODE;
90+
91+
const raw = useQuery(
92+
"xaaResourceApps:list" as any,
93+
enabled ? ({ organizationId } as any) : "skip",
94+
) as unknown | undefined;
95+
96+
const resourceApps = useMemo(() => normalizeList(raw), [raw]);
97+
98+
// Cast the new function names `as any` until backend `_generated` types
99+
// regenerate after the CRUD layer deploys.
100+
const upsertAction = useAction("xaaResourceApps:upsert" as any);
101+
const removeAction = useAction("xaaResourceApps:remove" as any);
102+
103+
const [error, setError] = useState<string | null>(null);
104+
105+
const upsert = useCallback(
106+
async (input: XaaResourceAppInput): Promise<{ id: string }> => {
107+
if (!organizationId) throw new Error("Organization is required");
108+
setError(null);
109+
try {
110+
const result = await upsertAction({
111+
organizationId,
112+
...input,
113+
} as any);
114+
return result as { id: string };
115+
} catch (err) {
116+
const message =
117+
err instanceof Error ? err.message : "Failed to save resource app";
118+
setError(message);
119+
throw err;
120+
}
121+
},
122+
[organizationId, upsertAction],
123+
);
124+
125+
const remove = useCallback(
126+
async (id: string): Promise<void> => {
127+
if (!organizationId) throw new Error("Organization is required");
128+
setError(null);
129+
try {
130+
await removeAction({ id, organizationId } as any);
131+
} catch (err) {
132+
const message =
133+
err instanceof Error ? err.message : "Failed to delete resource app";
134+
setError(message);
135+
throw err;
136+
}
137+
},
138+
[organizationId, removeAction],
139+
);
140+
141+
// Registration is hosted-only, so non-hosted renders never report loading.
142+
// Inside hosted mode, treat the auth-bootstrap window as loading so the list
143+
// shows a skeleton instead of flashing an empty state.
144+
const isLoading = HOSTED_MODE
145+
? isAuthLoading || (enabled && raw === undefined)
146+
: false;
147+
148+
return {
149+
resourceApps,
150+
isLoading,
151+
isAuthenticated: enabled,
152+
error,
153+
upsert,
154+
remove,
155+
};
156+
}

mcpjam-inspector/client/src/lib/xaa/types.ts

Lines changed: 41 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -158,6 +158,47 @@ export interface XAAStateMachine {
158158
resetFlow: () => void;
159159
}
160160

161+
// ---------------------------------------------------------------------------
162+
// Registered resource apps (hosted test bench). The wire shape mirrors the
163+
// backend's sanitized projection: the client secret is never returned, only a
164+
// `hasSecret` boolean.
165+
// ---------------------------------------------------------------------------
166+
167+
export type XaaResourceType = "rest" | "mcp";
168+
export type XaaAuthServerMode = "mcpjam" | "own";
169+
170+
export interface XaaResourceApp {
171+
id: string;
172+
name: string;
173+
resourceType: XaaResourceType;
174+
resourceUrl: string;
175+
authServerMode: XaaAuthServerMode;
176+
tokenEndpoint?: string;
177+
issuer?: string;
178+
targetClientId?: string;
179+
scopes?: string[];
180+
healthCheckUrl?: string;
181+
hasSecret: boolean;
182+
createdAt: number;
183+
updatedAt: number;
184+
}
185+
186+
/** Args accepted by the upsert action. `id` present = update. */
187+
export interface XaaResourceAppInput {
188+
id?: string;
189+
name: string;
190+
resourceType: XaaResourceType;
191+
resourceUrl: string;
192+
authServerMode: XaaAuthServerMode;
193+
tokenEndpoint?: string;
194+
issuer?: string;
195+
targetClientId?: string;
196+
/** Plaintext secret; sent only when set/changed, never returned. */
197+
secret?: string;
198+
scopes?: string[];
199+
healthCheckUrl?: string;
200+
}
201+
161202
export const EMPTY_XAA_FLOW_STATE: XAAFlowState = {
162203
isBusy: false,
163204
currentStep: "idle",

0 commit comments

Comments
 (0)