diff --git a/package.json b/package.json index 0dc755f..f1b65cc 100644 --- a/package.json +++ b/package.json @@ -102,6 +102,7 @@ "@inquirer/prompts": "8.3.0", "@inquirer/search": "^4.1.6", "@modelcontextprotocol/sdk": "^1.27.1", + "@opendatalabs/vana-sdk": "^3.9.0", "chromium-bidi": "15.0.0", "commander": "14.0.3", "fuse.js": "^7.1.0", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 0a28c44..5b9c2bc 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -16,6 +16,9 @@ importers: "@modelcontextprotocol/sdk": specifier: ^1.27.1 version: 1.27.1(zod@4.3.6) + "@opendatalabs/vana-sdk": + specifier: ^3.9.0 + version: 3.9.0(bufferutil@4.1.0)(react@19.2.4)(typescript@5.7.3)(utf-8-validate@5.0.10) chromium-bidi: specifier: 15.0.0 version: 15.0.0(devtools-protocol@0.0.1596832) @@ -2404,6 +2407,12 @@ packages: } engines: { node: ^14.21.3 || >=16 } + "@noble/secp256k1@2.3.0": + resolution: + { + integrity: sha512-0TQed2gcBbIrh7Ccyw+y/uZQvbJwm7Ao4scBUxqpBCcsOlZG0O4KGfjtNAy/li4W8n1xt3dxrwJ0beZ2h2G6Kw==, + } + "@nodelib/fs.scandir@2.1.5": resolution: { @@ -2524,6 +2533,18 @@ packages: integrity: sha512-U69T3ItWHvLwGg5eJ0n3I62nWuE6ilHlmz7zM0npLBRvPRd7e6NYmg54vvRtP5mZG7kZqZCFVdsTWo7BPtBujg==, } + "@opendatalabs/vana-sdk@3.9.0": + resolution: + { + integrity: sha512-oSkbWp9GHglSsVMbO68MEH3KAFty39LNQPJzHHDBqApZfV9/f8LDqfnL7u40ZCTYFq6ytAkvPM4o9QSGQy+3Bw==, + } + engines: { node: ">=22.0.0" } + peerDependencies: + react: ">=18.0.0" + peerDependenciesMeta: + react: + optional: true + "@paulmillr/qr@0.2.1": resolution: { @@ -5886,6 +5907,12 @@ packages: integrity: sha512-QXUSXI3QVc/gJME0dBpXrag1kbzOqCjCX8/b54ntNyW6sjtoqxqRk3LTmXzaJoh71zMsDCjM+47jS7XiwN/+fQ==, } + bn.js@4.12.4: + resolution: + { + integrity: sha512-njR1b+ixG2ufvL9Zn9JGneW+b5GV6jqpYyPPpg4QVt723b5kJPGUczkUyWEH9BwEA74UakJZ43I4FDLBF7ci0g==, + } + bn.js@5.2.2: resolution: { @@ -5936,6 +5963,12 @@ packages: } engines: { node: ">=8" } + brorand@1.1.0: + resolution: + { + integrity: sha512-cKV8tMCEpQs4hK/ik71d6LrPOnpkpGBR0wzxqr68g2m/LB2GxVYQroAjMJZRVM1Y4BCjCKc3vAamxSzOY2RP+w==, + } + browserslist@4.28.1: resolution: { @@ -6823,6 +6856,12 @@ packages: integrity: sha512-9tfDXhJ4RKFNerfjdCcZfufu49vg620741MNs26a9+bhLThdB+plgMeou98CAaHu/WATj2iHOOHTp1hWtABj2A==, } + elliptic@6.6.1: + resolution: + { + integrity: sha512-RaddvvMatK2LJHqFJ+YA4WysVN5Ita9E35botqIYspQ4TkRAlCicdzKOjlyv/1Za5RyTNn7di//eEV0uTAfe3g==, + } + emoji-regex@10.6.0: resolution: { @@ -7903,6 +7942,12 @@ packages: } engines: { node: ^20.17.0 || >=22.9.0 } + hash.js@1.1.7: + resolution: + { + integrity: sha512-taOaskGt4z4SOANNseOviYDvjEJinIkRgmp7LbKP2YTTmVxWBl87s/uzK9r+44BclBSp2X7K1hqeNfz9JbBeXA==, + } + html-encoding-sniffer@6.0.0: resolution: { @@ -7910,6 +7955,12 @@ packages: } engines: { node: ^20.19.0 || ^22.12.0 || >=24.0.0 } + hmac-drbg@1.0.1: + resolution: + { + integrity: sha512-Tti3gMqLdZfhOQY1Mzf/AanLiqh1WTiJgEj26ZuYQ9fbkLomzGchCws4FyrSd4VkpBfiNhaE1On+lOz894jvXg==, + } + http-errors@2.0.1: resolution: { @@ -9101,6 +9152,18 @@ packages: } engines: { node: ">=18" } + minimalistic-assert@1.0.1: + resolution: + { + integrity: sha512-UtJcAD4yEaGtjPezWuO9wC4nwUnVH/8/Im3yEHQP4b67cXlD/Qr9hdITCU1xDbSEXg2XKNaP8jsReV7vQd00/A==, + } + + minimalistic-crypto-utils@1.0.1: + resolution: + { + integrity: sha512-JIYlbt6g8i5jKfJ3xz7rF0LXmv2TkDxBLUkiBeZ7bAx4GnnNMr8xFpGnOxn6GhTEHx3SjRrZEoU+j04prX1ktg==, + } + minimatch@10.1.2: resolution: { @@ -9325,6 +9388,12 @@ packages: integrity: sha512-Ntyt4AIXyaLIuMHF6IOoTakB3K+RWxwtsHNRxllEoA6vPwP9o4866g6YWDLUdnucilZhmkxiHwHr11gAENw+QA==, } + node-addon-api@5.1.0: + resolution: + { + integrity: sha512-eh0GgfEkpnoWDq+VY8OyvYhFEzBk6jIYbRKdIlyTiAXIVJ8PyBaKb0rp7oDtoddbdoHWhq8wwr+XZ81F1rpNdA==, + } + node-domexception@1.0.0: resolution: { @@ -9620,6 +9689,18 @@ packages: integrity: sha512-opyTPaunsklCBpTK8JGef6mfPhLSnyy5a0IN9vKtx3+4aExf+KxEqYwIy3hqkedXIB97u357uLMJsOnm3GVjsw==, } + openpgp@6.3.1: + resolution: + { + integrity: sha512-7oSPvmlKPojxFoyelT5DWPIAVmqWZh4qU/5pO6bdoShEtRpCw9Sye9IXUQj6EFM3XpgGssqccAr705YtTcLNQw==, + } + engines: { node: ">= 18.0.0", typescript: ">= 5.0.0" } + peerDependencies: + "@openpgp/web-stream-tools": ~0.3.0 + peerDependenciesMeta: + "@openpgp/web-stream-tools": + optional: true + optionator@0.9.4: resolution: { @@ -10783,6 +10864,13 @@ packages: integrity: sha512-eNv+WrVbKu1f3vbYJT/xtiF5syA5HPIMtf9IgY/nKg0sWqzAUEvqY/xm7OcZc/qafLx/iO9FgOmeSAp4v5ti/Q==, } + secp256k1@5.0.1: + resolution: + { + integrity: sha512-lDFs9AAIaWP9UCdtWrotXWWF9t8PWgQDcxqgAnpM9rMqxb3Oaq2J0thzPVSxBwdJgyQtkU/sYtFtbM1RSt/iYA==, + } + engines: { node: ">=18.0.0" } + secure-json-parse@2.7.0: resolution: { @@ -14107,6 +14195,8 @@ snapshots: "@noble/hashes@1.8.0": {} + "@noble/secp256k1@2.3.0": {} + "@nodelib/fs.scandir@2.1.5": dependencies: "@nodelib/fs.stat": 2.0.5 @@ -14187,6 +14277,23 @@ snapshots: "@open-draft/until@2.1.0": {} + "@opendatalabs/vana-sdk@3.9.0(bufferutil@4.1.0)(react@19.2.4)(typescript@5.7.3)(utf-8-validate@5.0.10)": + dependencies: + "@noble/hashes": 1.8.0 + "@noble/secp256k1": 2.3.0 + abitype: 1.2.3(typescript@5.7.3)(zod@4.3.6) + openpgp: 6.3.1 + viem: 2.46.2(bufferutil@4.1.0)(typescript@5.7.3)(utf-8-validate@5.0.10)(zod@4.3.6) + zod: 4.3.6 + optionalDependencies: + react: 19.2.4 + secp256k1: 5.0.1 + transitivePeerDependencies: + - "@openpgp/web-stream-tools" + - bufferutil + - typescript + - utf-8-validate + "@paulmillr/qr@0.2.1": {} "@phosphor-icons/webcomponents@2.1.5": @@ -18007,6 +18114,8 @@ snapshots: blakejs@1.2.1: {} + bn.js@4.12.4: {} + bn.js@5.2.2: {} body-parser@2.2.2: @@ -18046,6 +18155,8 @@ snapshots: dependencies: fill-range: 7.1.1 + brorand@1.1.0: {} + browserslist@4.28.1: dependencies: baseline-browser-mapping: 2.9.19 @@ -18489,6 +18600,16 @@ snapshots: electron-to-chromium@1.5.286: {} + elliptic@6.6.1: + dependencies: + bn.js: 4.12.4 + brorand: 1.1.0 + hash.js: 1.1.7 + hmac-drbg: 1.0.1 + inherits: 2.0.4 + minimalistic-assert: 1.0.1 + minimalistic-crypto-utils: 1.0.1 + emoji-regex@10.6.0: {} emoji-regex@8.0.0: {} @@ -19172,6 +19293,11 @@ snapshots: headers-polyfill@4.0.3: {} + hash.js@1.1.7: + dependencies: + inherits: 2.0.4 + minimalistic-assert: 1.0.1 + help-me@5.0.0: {} highlight.js@10.7.3: {} @@ -19194,6 +19320,12 @@ snapshots: transitivePeerDependencies: - "@noble/hashes" + hmac-drbg@1.0.1: + dependencies: + hash.js: 1.1.7 + minimalistic-assert: 1.0.1 + minimalistic-crypto-utils: 1.0.1 + http-errors@2.0.1: dependencies: depd: 2.0.0 @@ -19794,6 +19926,10 @@ snapshots: mimic-function@5.0.1: {} + minimalistic-assert@1.0.1: {} + + minimalistic-crypto-utils@1.0.1: {} + minimatch@10.1.2: dependencies: "@isaacs/brace-expansion": 5.0.1 @@ -19959,6 +20095,8 @@ snapshots: node-addon-api@2.0.2: {} + node-addon-api@5.1.0: {} + node-domexception@1.0.0: {} node-emoji@2.2.0: @@ -20078,6 +20216,8 @@ snapshots: openapi-typescript-helpers@0.0.15: {} + openpgp@6.3.1: {} + optionator@0.9.4: dependencies: deep-is: 0.1.4 @@ -20146,6 +20286,21 @@ snapshots: transitivePeerDependencies: - zod + ox@0.12.4(typescript@5.7.3)(zod@4.3.6): + dependencies: + "@adraffy/ens-normalize": 1.11.1 + "@noble/ciphers": 1.3.0 + "@noble/curves": 1.9.1 + "@noble/hashes": 1.8.0 + "@scure/bip32": 1.7.0 + "@scure/bip39": 1.6.0 + abitype: 1.2.3(typescript@5.7.3)(zod@4.3.6) + eventemitter3: 5.0.1 + optionalDependencies: + typescript: 5.7.3 + transitivePeerDependencies: + - zod + ox@0.6.7(typescript@5.7.3)(zod@3.25.76): dependencies: "@adraffy/ens-normalize": 1.11.1 @@ -20909,6 +21064,13 @@ snapshots: scheduler@0.27.0: {} + secp256k1@5.0.1: + dependencies: + elliptic: 6.6.1 + node-addon-api: 5.1.0 + node-gyp-build: 4.8.4 + optional: true + secure-json-parse@2.7.0: {} secure-password-utilities@0.2.1: {} @@ -21741,6 +21903,23 @@ snapshots: - utf-8-validate - zod + viem@2.46.2(bufferutil@4.1.0)(typescript@5.7.3)(utf-8-validate@5.0.10)(zod@4.3.6): + dependencies: + "@noble/curves": 1.9.1 + "@noble/hashes": 1.8.0 + "@scure/bip32": 1.7.0 + "@scure/bip39": 1.6.0 + abitype: 1.2.3(typescript@5.7.3)(zod@4.3.6) + isows: 1.0.7(ws@8.18.3(bufferutil@4.1.0)(utf-8-validate@5.0.10)) + ox: 0.12.4(typescript@5.7.3)(zod@4.3.6) + ws: 8.18.3(bufferutil@4.1.0)(utf-8-validate@5.0.10) + optionalDependencies: + typescript: 5.7.3 + transitivePeerDependencies: + - bufferutil + - utf-8-validate + - zod + vite@7.3.1(@types/node@20.19.33)(jiti@2.6.1)(lightningcss@1.30.2)(yaml@2.8.2): dependencies: esbuild: 0.27.4 diff --git a/scripts/assert-sea-artifact.mjs b/scripts/assert-sea-artifact.mjs index fcb9452..0ad6f70 100644 --- a/scripts/assert-sea-artifact.mjs +++ b/scripts/assert-sea-artifact.mjs @@ -11,6 +11,7 @@ const checksumPath = path.resolve(requiredArg(args, "checksum")); const platform = requiredArg(args, "platform"); const binaryName = args.get("binary-name") ?? (platform === "win32" ? "vana.exe" : "vana"); +const archiveListMaxBuffer = 64 * 1024 * 1024; await assertExists( artifactDir, @@ -136,6 +137,7 @@ function listArchiveEntries({ archivePath, platform }) { ], { encoding: "utf8", + maxBuffer: archiveListMaxBuffer, }, ); return raw @@ -146,6 +148,7 @@ function listArchiveEntries({ archivePath, platform }) { const raw = execFileSync("tar", ["-tzf", archivePath], { encoding: "utf8", + maxBuffer: archiveListMaxBuffer, }); return raw .split(/\r?\n/) diff --git a/src/server/request-signer.ts b/src/server/request-signer.ts index f536472..2fd2dc6 100644 --- a/src/server/request-signer.ts +++ b/src/server/request-signer.ts @@ -1,4 +1,5 @@ import { createHash } from "node:crypto"; +import { buildWeb3SignedHeader } from "@opendatalabs/vana-sdk/auth/web3-signed-builder"; import { privateKeyToAccount } from "viem/accounts"; import type { RequestSignerConfig } from "../core/types.js"; @@ -6,14 +7,6 @@ const EMPTY_BODY_HASH = "sha256:e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855"; type BodyHashFormat = NonNullable; -function base64urlEncode(input: string): string { - return Buffer.from(input, "utf-8") - .toString("base64") - .replace(/\+/g, "-") - .replace(/\//g, "_") - .replace(/=+$/, ""); -} - function canonicalizeJson(obj: unknown): unknown { if (obj === null || typeof obj !== "object") return obj; if (Array.isArray(obj)) return obj.map(canonicalizeJson); @@ -79,34 +72,18 @@ export function createRequestSigner( async signRequest(params): Promise { const now = Math.floor(Date.now() / 1000); + const bodyHash = computeBodyHash(params.body, bodyHashFormat); - const payload: Record = { + return buildWeb3SignedHeader({ aud: params.aud, - bodyHash: computeBodyHash(params.body, bodyHashFormat), exp: now + 300, + grantId: params.grantId, iat: now, method: params.method, uri: params.uri, - }; - - if (params.grantId !== undefined) { - payload["grantId"] = params.grantId; - } - - // Sort keys for deterministic serialization - const sortedPayload = Object.keys(payload) - .sort() - .reduce>((acc, key) => { - acc[key] = payload[key]; - return acc; - }, {}); - - const payloadJson = JSON.stringify(sortedPayload); - const payloadBase64 = base64urlEncode(payloadJson); - - const signature = await account.signMessage({ message: payloadBase64 }); - - return `Web3Signed ${payloadBase64}.${signature}`; + bodyHash, + signMessage: (message: string) => account.signMessage({ message }), + }); }, }; } diff --git a/src/server/session-relay.ts b/src/server/session-relay.ts index 930fc12..bfc2894 100644 --- a/src/server/session-relay.ts +++ b/src/server/session-relay.ts @@ -1,21 +1,65 @@ +import { + createSessionRelayBuilderClient, + type SessionRelayInitResult as SdkRelaySessionInitResult, +} from "@opendatalabs/vana-sdk/session-relay"; +import { privateKeyToAccount } from "viem/accounts"; import { ConnectError, ConnectErrorCode } from "../core/errors.js"; import type { SessionRelayConfig, SessionInitParams, SessionPollResult, } from "../core/types.js"; -import { createRequestSigner } from "./request-signer.js"; /** Raw response from the Session Relay init endpoint. */ -export interface RelaySessionInitResult { - sessionId: string; - deepLinkUrl: string; - expiresAt: string; +export type RelaySessionInitResult = SdkRelaySessionInitResult; + +interface RelayErrorLike extends Error { + details: { + status?: number; + relayErrorCode?: string; + }; +} + +function isRelayErrorLike(err: unknown): err is RelayErrorLike { + return ( + err instanceof Error && + "details" in err && + err.details !== null && + typeof err.details === "object" + ); +} + +function mapRelayError( + err: unknown, + fallbackCode: ConnectErrorCode, +): ConnectError { + if (err instanceof ConnectError) { + return err; + } + + if (isRelayErrorLike(err)) { + const relayCode = err.details.relayErrorCode; + const code = + relayCode === "SESSION_RELAY_POLL_TIMEOUT" + ? ConnectErrorCode.POLL_TIMEOUT + : (relayCode ?? fallbackCode); + return new ConnectError(err.message, code, err.details.status); + } + + if (err instanceof Error) { + return new ConnectError(err.message, fallbackCode); + } + + return new ConnectError(String(err), fallbackCode); } /** * Low-level client for the Session Relay service. * + * This is a CLI adapter over the Vana SDK Session Relay integration. Session + * Relay is a Vana-operated service integration for app handoff flows, not a + * protocol-core primitive. + * * @see {@link createSessionRelay} to create an instance. */ export interface SessionRelay { @@ -45,97 +89,44 @@ export interface SessionRelay { * @returns A {@link SessionRelay} instance. */ export function createSessionRelay(config: SessionRelayConfig): SessionRelay { - const baseUrl = config.sessionRelayUrl.replace(/\/+$/, ""); - const signer = createRequestSigner({ privateKey: config.privateKey }); + const account = privateKeyToAccount(config.privateKey); + const relay = createSessionRelayBuilderClient({ + baseUrl: config.sessionRelayUrl, + granteeAddress: config.granteeAddress, + signMessage: (message: string) => account.signMessage({ message }), + }); return { async initSession( params: SessionInitParams, ): Promise { - const body = JSON.stringify({ - granteeAddress: config.granteeAddress, - scopes: params.scopes, - ...(params.webhookUrl && { webhookUrl: params.webhookUrl }), - ...(params.appUserId && { app_user_id: params.appUserId }), - }); - - const authHeader = await signer.signRequest({ - aud: baseUrl, - method: "POST", - uri: "/v1/session/init", - body, - }); - - const res = await fetch(`${baseUrl}/v1/session/init`, { - method: "POST", - headers: { - "Content-Type": "application/json", - Authorization: authHeader, - }, - body, - }); - - if (!res.ok) { - const errorBody = await res.json().catch(() => ({})); - const errorMsg = - (errorBody as Record).error && - typeof (errorBody as Record).error === "object" - ? ((errorBody as Record>).error - .message as string) - : `Session init failed: ${res.status}`; - throw new ConnectError( - errorMsg, - ((errorBody as Record>).error - ?.errorCode as string) ?? ConnectErrorCode.SESSION_INIT_FAILED, - res.status, - ); + try { + return await relay.initSession(params); + } catch (err) { + throw mapRelayError(err, ConnectErrorCode.SESSION_INIT_FAILED); } - - return (await res.json()) as RelaySessionInitResult; }, async pollSession(sessionId: string): Promise { - const res = await fetch(`${baseUrl}/v1/session/${sessionId}/poll`); - - if (!res.ok) { - const errorBody = await res.json().catch(() => ({})); - throw new ConnectError( - `Poll failed: ${res.status}`, - ((errorBody as Record>).error - ?.errorCode as string) ?? ConnectErrorCode.POLL_FAILED, - res.status, - ); + try { + return await relay.pollSession(sessionId); + } catch (err) { + throw mapRelayError(err, ConnectErrorCode.POLL_FAILED); } - - return (await res.json()) as SessionPollResult; }, async pollUntilComplete( sessionId: string, opts?: { interval?: number; timeout?: number }, ): Promise { - const interval = opts?.interval ?? 2000; - const timeout = opts?.timeout ?? 900_000; // 15 minutes - const deadline = Date.now() + timeout; - - while (Date.now() < deadline) { - const result = await this.pollSession(sessionId); - - if ( - result.status === "approved" || - result.status === "denied" || - result.status === "expired" - ) { - return result; - } - - await new Promise((resolve) => setTimeout(resolve, interval)); + try { + return await relay.pollUntilComplete(sessionId, { + intervalMs: opts?.interval, + timeoutMs: opts?.timeout, + }); + } catch (err) { + throw mapRelayError(err, ConnectErrorCode.POLL_FAILED); } - - throw new ConnectError( - "Polling timed out", - ConnectErrorCode.POLL_TIMEOUT, - ); }, }; } diff --git a/test/server/connect.test.ts b/test/server/connect.test.ts index fffd982..2c626d6 100644 --- a/test/server/connect.test.ts +++ b/test/server/connect.test.ts @@ -178,7 +178,7 @@ describe("connect", () => { const initBody = JSON.parse(mockFetch.mock.calls[0][1].body); expect(initBody.webhookUrl).toBe("https://webhook.example.com"); - expect(initBody.app_user_id).toBe("user-42"); + expect(initBody.appUserId).toBe("user-42"); }); it("only makes one fetch call (no polling or data fetch)", async () => { diff --git a/test/server/session-relay.test.ts b/test/server/session-relay.test.ts index cebaff6..feceec1 100644 --- a/test/server/session-relay.test.ts +++ b/test/server/session-relay.test.ts @@ -1,6 +1,6 @@ import { describe, it, expect, vi, beforeEach, afterEach } from "vitest"; import { createSessionRelay } from "../../src/server/session-relay.js"; -import { ConnectError } from "../../src/core/errors.js"; +import { ConnectError, ConnectErrorCode } from "../../src/core/errors.js"; const TEST_PRIVATE_KEY = "0xac0974bec39a17e36ba4a6b4d238ff944bacb478cbed5efcae784d7bf4f2ff80" as const; @@ -93,10 +93,10 @@ describe("createSessionRelay", () => { const callBody = JSON.parse(mockFetch.mock.calls[0][1].body); expect(callBody.webhookUrl).toBe("https://webhook.example.com"); - expect(callBody.app_user_id).toBe("user-42"); + expect(callBody.appUserId).toBe("user-42"); }); - it("throws ConnectError on non-ok response", async () => { + it("maps SDK init errors to ConnectError", async () => { mockFetch.mockResolvedValueOnce({ ok: false, status: 403, @@ -114,9 +114,14 @@ describe("createSessionRelay", () => { sessionRelayUrl: RELAY_URL, }); - await expect(relay.initSession({ scopes: ["test"] })).rejects.toThrow( - ConnectError, - ); + const promise = relay.initSession({ scopes: ["test"] }); + + await expect(promise).rejects.toThrow(ConnectError); + await expect(promise).rejects.toMatchObject({ + code: "BUILDER_NOT_REGISTERED", + message: "Builder not registered", + statusCode: 403, + }); }); }); @@ -138,6 +143,7 @@ describe("createSessionRelay", () => { expect(result.status).toBe("pending"); expect(mockFetch).toHaveBeenCalledWith( `${RELAY_URL}/v1/session/sess-123/poll`, + { method: "GET" }, ); }); @@ -166,12 +172,15 @@ describe("createSessionRelay", () => { expect(result.grant?.grantId).toBe("grant-1"); }); - it("throws ConnectError on non-ok response", async () => { + it("maps SDK poll errors to ConnectError", async () => { mockFetch.mockResolvedValueOnce({ ok: false, status: 404, json: async () => ({ - error: { errorCode: "SESSION_NOT_FOUND" }, + error: { + errorCode: "SESSION_NOT_FOUND", + message: "Session not found", + }, }), }); @@ -181,9 +190,14 @@ describe("createSessionRelay", () => { sessionRelayUrl: RELAY_URL, }); - await expect(relay.pollSession("nonexistent")).rejects.toThrow( - ConnectError, - ); + const promise = relay.pollSession("nonexistent"); + + await expect(promise).rejects.toThrow(ConnectError); + await expect(promise).rejects.toMatchObject({ + code: "SESSION_NOT_FOUND", + message: "Session not found", + statusCode: 404, + }); }); }); @@ -254,12 +268,14 @@ describe("createSessionRelay", () => { sessionRelayUrl: RELAY_URL, }); - await expect( - relay.pollUntilComplete("sess-123", { - interval: 10, - timeout: 50, - }), - ).rejects.toThrow("Polling timed out"); + const promise = relay.pollUntilComplete("sess-123", { + interval: 10, + timeout: 50, + }); + await expect(promise).rejects.toThrow("Polling timed out"); + await expect(promise).rejects.toMatchObject({ + code: ConnectErrorCode.POLL_TIMEOUT, + }); }); }); });