diff --git a/src/core.ts b/src/core.ts index 55f1554..62b8f2c 100644 --- a/src/core.ts +++ b/src/core.ts @@ -1,15 +1,39 @@ import type { SorokitClient, SorokitClientConfig } from "./client/createSorokitClient"; +import { toSorokitResult } from "./shared/response"; import type { SorokitResult } from "./shared/response"; /** - * Load the full Sorokit client asynchronously from the lightweight core entry. - * Soroban and anchor integration operations remain split and load on demand. + * Lightweight entry point for the Sorokit SDK. + * + * ## Required opt-in configuration + * + * Creating a client does not implicitly enable optional subsystems: + * + * - Telemetry is inactive until `initTelemetry({ serviceName: "..." })` is + * called. Telemetry helpers intentionally no-op before initialization. + * - Wallet operations require an adapter supplied to the wallet operation (or + * an explicit adapter registration through the wallet module); the client + * factory does not silently discover a wallet. + * - Network-dependent operations require the corresponding Horizon/RPC URL + * and network passphrase in `network`. + * + * Every client factory and operation uses the same `SorokitResult` envelope: + * inspect `result.status === "ok"` before reading `result.data`; errors are + * returned in `result.error` rather than thrown. For third-party callbacks or + * legacy throwing helpers, use `toSorokitResult(() => operation())` at the + * boundary to preserve that contract. */ export async function createSorokitClient( config: SorokitClientConfig, ): Promise> { - const clientModule = await import("./client/createSorokitClient"); - return clientModule.createSorokitClient(config); + try { + const clientModule = await import("./client/createSorokitClient"); + return clientModule.createSorokitClient(config); + } catch (cause) { + return toSorokitResult(() => { + throw cause; + }); + } } export type { SorokitClient, SorokitClientConfig }; diff --git a/src/index.ts b/src/index.ts index 7d24f04..b2239d9 100644 --- a/src/index.ts +++ b/src/index.ts @@ -1322,6 +1322,7 @@ export { isErrorCode, isOk, ok, + toSorokitResult, } from "./shared/response"; export type { RecoveryAttempt, diff --git a/src/integration/didSupport.ts b/src/integration/didSupport.ts index 77e7736..a51cb1c 100644 --- a/src/integration/didSupport.ts +++ b/src/integration/didSupport.ts @@ -95,6 +95,69 @@ function parseDID(did: string): SorokitResult { return err(SorokitErrorCode.INVALID_CONFIG, `Invalid DID format: ${did}`); } +/** + * Validate the minimum DID Core shape required by Sorokit's Stellar DID + * integration. Resolver implementations return untrusted JSON at runtime, so + * the TypeScript interface alone is not sufficient protection. + */ +function validateResolvedDocument( + document: unknown, + did: string, + publicKey: string, +): SorokitResult { + if (!document || typeof document !== "object") { + return err(SorokitErrorCode.INVALID_CONFIG, "Resolved DID document must be an object"); + } + const candidate = document as Partial; + if (candidate.id !== did) { + return err( + SorokitErrorCode.INVALID_CONFIG, + `Resolved DID document id must match the requested DID: ${did}`, + ); + } + if ( + !Array.isArray(candidate["@context"]) || + !candidate["@context"].includes("https://www.w3.org/ns/did/v1") + ) { + return err( + SorokitErrorCode.INVALID_CONFIG, + "Resolved DID document must include the W3C DID Core context", + ); + } + if (!Array.isArray(candidate.verificationMethod) || candidate.verificationMethod.length === 0) { + return err( + SorokitErrorCode.INVALID_CONFIG, + "Resolved DID document must contain at least one verification method", + ); + } + for (const method of candidate.verificationMethod) { + if ( + !method || + typeof method !== "object" || + typeof method.id !== "string" || + method.type !== "StellarVerificationKey2024" || + method.controller !== did || + method.publicKeyMultibase !== publicKey + ) { + return err( + SorokitErrorCode.INVALID_CONFIG, + "Resolved DID document contains an invalid Stellar verification method", + ); + } + } + if ( + !Array.isArray(candidate.authentication) || + candidate.authentication.length === 0 || + candidate.authentication.some((id) => typeof id !== "string") + ) { + return err( + SorokitErrorCode.INVALID_CONFIG, + "Resolved DID document must contain a non-empty authentication array", + ); + } + return ok(document as StellarDIDDocument); +} + function documentFor( did: string, publicKey: string, @@ -182,6 +245,8 @@ export async function resolveDID( "Custom resolver returned no document", ); } + const documentResult = validateResolvedDocument(resolvedDoc, did, parsed.data); + if (documentResult.status === "error") return documentResult; // Check for expiry if (resolvedDoc.expires) { const expiresAt = new Date(resolvedDoc.expires); diff --git a/src/shared/response.ts b/src/shared/response.ts index e282679..d1d89b5 100644 --- a/src/shared/response.ts +++ b/src/shared/response.ts @@ -251,6 +251,27 @@ export function err( }) as SorokitResult; } +/** + * Normalize a synchronous or asynchronous operation to the SDK result + * contract. Use this at integration boundaries where a third-party callback + * or legacy helper may throw; public SDK operations should return the + * resulting `SorokitResult` instead of leaking the exception to callers. + */ +export async function toSorokitResult( + operation: () => T | Promise, + code: SorokitErrorCode = SorokitErrorCode.INTERNAL, +): Promise> { + try { + return ok(await operation()); + } catch (cause) { + return err( + code, + cause instanceof Error ? cause.message : String(cause), + cause, + ); + } +} + export function attachTraceId( result: SorokitResult, traceId: string, diff --git a/src/tests/didSupport.test.ts b/src/tests/didSupport.test.ts index 2b3db60..dd52e63 100644 --- a/src/tests/didSupport.test.ts +++ b/src/tests/didSupport.test.ts @@ -304,6 +304,42 @@ describe("resolveDID", () => { expect(result.error.code).toBe("INVALID_CONFIG"); expect(result.error.message).toContain("expired"); }); + + it("rejects a custom resolver document with a mismatched id", async () => { + const result = await resolveDID(TEST_DID, { + resolver: resolverReturning({ ...PREBUILT_DOCUMENT, id: OTHER_DID }), + }); + + expect(result.status).toBe("error"); + if (result.status !== "error") return; + expect(result.error.code).toBe("INVALID_CONFIG"); + expect(result.error.message).toContain("id must match"); + }); + + it("rejects a custom resolver document without a verification method", async () => { + const result = await resolveDID(TEST_DID, { + resolver: resolverReturning({ ...PREBUILT_DOCUMENT, verificationMethod: [] }), + }); + + expect(result.status).toBe("error"); + if (result.status !== "error") return; + expect(result.error.code).toBe("INVALID_CONFIG"); + expect(result.error.message).toContain("verification method"); + }); + + it("rejects a custom resolver document with an invalid DID context", async () => { + const result = await resolveDID(TEST_DID, { + resolver: resolverReturning({ + ...PREBUILT_DOCUMENT, + "@context": ["https://example.com/not-a-did-context"], + }), + }); + + expect(result.status).toBe("error"); + if (result.status !== "error") return; + expect(result.error.code).toBe("INVALID_CONFIG"); + expect(result.error.message).toContain("DID Core context"); + }); }); // ═════════════════════════════════════════════════════════════════════════════ diff --git a/src/tests/result.test.ts b/src/tests/result.test.ts index 468b167..6d0d462 100644 --- a/src/tests/result.test.ts +++ b/src/tests/result.test.ts @@ -11,10 +11,43 @@ import { defaultRecoveryGuidance, sanitizeErrorContext, attachTraceId, + toSorokitResult, SorokitErrorCategory, } from "../shared/response"; describe("shared/response", () => { + describe("toSorokitResult()", () => { + it("wraps successful synchronous values", async () => { + await expect(toSorokitResult(() => 42)).resolves.toMatchObject({ + status: "ok", + data: 42, + error: null, + }); + }); + + it("converts synchronous throws into structured errors", async () => { + const result = await toSorokitResult(() => { + throw new Error("legacy helper failed"); + }); + expect(result.status).toBe("error"); + if (result.status !== "error") return; + expect(result.error.code).toBe(SorokitErrorCode.INTERNAL); + expect(result.error.message).toBe("legacy helper failed"); + }); + + it("converts rejected promises using the caller's error code", async () => { + const result = await toSorokitResult( + async () => { + throw new Error("adapter rejected"); + }, + SorokitErrorCode.WALLET_CONNECT_FAILED, + ); + expect(result.status).toBe("error"); + if (result.status !== "error") return; + expect(result.error.code).toBe(SorokitErrorCode.WALLET_CONNECT_FAILED); + }); + }); + describe("ok()", () => { it("returns status ok with data", () => { const result = ok({ value: 42 });