diff --git a/CHANGELOG.md b/CHANGELOG.md index b79487b..e8d77ca 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,7 +6,20 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), ## [Unreleased] +### Changed +- **`validateResponses` now defaults to `true`** (was `false`). Every response from `AccessService.checkAccess`/`checkRoleAccess`, `MembershipService.getMembership`, `RolesService.getRoles`/`getUserRoles`, and `GuildsService.getGuild`/`getGuildConfig` is now shape-checked before being returned, out of the box — no config needed. Set `validateResponses: false` on `GuildPassClientConfig` to restore the previous unchecked behavior. + - Unknown/extra response fields are always passed through untouched (never stripped, never rejected) — see the passthrough policy in [`docs/serialization-validation.md`](docs/serialization-validation.md). Only a missing required field or a wrong primitive type fails validation. + - Fixed two over-strict validation bugs found while making this the default, both of which would have rejected common, legitimate API responses: + - `AccessCheckResult.requiredRoles`/`matchedRoles` were validated as `nonEmptyArray()`, rejecting the legitimate empty-array case (public resource with no required roles, or a denial with no matched roles). Both are now `array()`. + - The `optional()` schema combinator only accepted `undefined`, not `null`, for an absent field — but a JSON API response can only omit a key or send `null`; it has no way to produce a literal `undefined`. Every optional field across every response guard (`AccessCheckResult.reason`, `Guild.description`, etc.) now accepts `null` too. + - This is a behavior change for any consumer relying on receiving a malformed/incomplete API response unchecked; per this project's pre-1.0 versioning policy (see README → Versioning) this ships as a **minor** bump. + ### Added +- **Unified request/response validation layer.** Every HTTP domain service call (`AccessService.checkAccess`/`checkRoleAccess`, `MembershipService.getMembership`, `RolesService.getRoles`/`getUserRoles`, `GuildsService.getGuild`/`getGuildConfig`) now validates its request parameters structurally before transmission, pairing the response-shape guards that already existed for each model. See [`docs/serialization-validation.md`](docs/serialization-validation.md) for the schema/error/unknown-field policy. + - New: `assertValidRequest`, and per-model guards `isAccessCheckParams`, `isRoleAccessCheckParams`, `isMembershipParams`, `isGetRolesParams`, `isGetUserRolesParams`, `isGetGuildParams` (exported from the root package, mirroring the existing `responseGuards`/`assertValidResponse`). + - No new runtime dependency — built on the existing zero-dependency combinator DSL in `src/validation/schema.ts`. + - Backward compatible: request guards are structural-only (type/presence checks) and run before, not instead of, the existing field-level validators (`validateAddress`, `validateGuildId`, ...), so no existing error code or message changes for any input that was already valid or already rejected. +- `RequestOptions.confirmations?: BlockTag` is now part of the public type (previously used internally via an `as any` cast in `JsonRpcContractProvider`, undocumented and inaccessible to TypeScript consumers). - **SIWE (Sign-In With Ethereum) helpers** — resolves [#158](https://github.com/Adamantine-Guild/guildpass-sdk/issues/158). - `formatSiweMessage(msg)` — formats a `SiweMessage` object into a canonical EIP-4361 string ready for wallet signing. - `parseSiweMessage(raw)` — parses a raw EIP-4361 string into a typed `SiweMessage`; returns a `SiweParseResult` (never throws). @@ -24,6 +37,15 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), - Precedence chain: `contractProvider` > `contractReadConsensus` > default. - Per-item batch quorum: items whose front-runner is below `minProviders` become `{ status: "error", error: "Consensus mismatch at batch index i: ..." }` rather than throwing the whole batch. +### Fixed +- **`pnpm build` was broken** on `main` (missing `constantTimeEqual` export from `src/utils/index.ts`, `Middleware` type not re-exported from `http.types.ts`, a `strictPropertyInitialization` violation in `HttpClient`, an ethers v5-only import path in `src/utils/merkleTree.ts`, stale v5-style `ethers.providers.Provider`/`BigNumber` usage in `src/contracts/contractHelpers.ts`, and a `Headers` typing mismatch in `src/network/fetchTransport.ts`). All fixed; `pnpm build`, `pnpm typecheck`, and `pnpm lint` are green again. +- **`FetchTransport` captured `globalThis.fetch` once, at construction time**, instead of resolving it lazily per request. A `fetch` installed or replaced after the client was constructed (a polyfill, or `vi.stubGlobal` in tests) was silently never used — the client kept issuing requests through whatever `fetch` existed when it was built. +- **Cross-caller cancellation leak in request coalescing**: two concurrent calls sharing a cache key but only one carrying an `AbortSignal` shared a single in-flight request, so aborting the signalled caller also rejected the other, unrelated caller. A signal now opts a call out of coalescing by default (override with `deduplicate: true`); `checkAccessBatch` never coalesces its items with each other or with a concurrent lone call. +- **Errors on HTTP error responses (4xx/5xx) never carried `requestMeta`** (`requestId`, `correlationId`, `traceId`) — `extractMeta()` expected a `Headers`-like object with `.get()`, but was called with the plain `Record` returned by `TransportResponse.getHeaders()`, so every lookup silently returned `undefined`. +- **A pre-aborted `AbortSignal` threw a generic `GuildPassNetworkError`** instead of the more specific `GuildPassCancellationError` that every other cancellation path already threw. +- Several service method overloads (`getMembership`, `getGuild`, `getGuildConfig`, `checkAccess`, `checkRoleAccess`) were missing the general `(params, options?: RequestOptions)` overload declaration — TypeScript consumers calling them with plain options (`{ timeoutMs }`, `{ signal }`, `{ retry }`, no `includeMeta`) got a real compile error in their own code, even though the call worked fine at runtime. +- `scripts/check-bundle-size.mjs` didn't mark `ethers`/`viem` as external, so `pnpm size` either crashed or wildly overstated every entry's size by bundling the full libraries into the measurement. + ## [0.1.0] - 2026-06-29 ### Added diff --git a/api-report/guildpass-sdk.api.md b/api-report/guildpass-sdk.api.md index 39cf119..c50b09c 100644 --- a/api-report/guildpass-sdk.api.md +++ b/api-report/guildpass-sdk.api.md @@ -23,10 +23,30 @@ export type AbiParameter = { // @public export const ACCESS_CONTROL_INTERFACE_ID = "0x7965db0b"; +// @public (undocumented) +export type AccessCheckBatchByResourceParams = { + walletAddress: string; + guildId: string; + resourceIds: string[]; +}; + +// @public (undocumented) +export type AccessCheckBatchByResourceResult = Record; + // @public (undocumented) export type AccessCheckBatchOptions = { concurrency?: number; failFast?: boolean; + adaptiveConcurrency?: boolean; +}; + +// @public (undocumented) +export type AccessCheckBatchResourceEntry = { + status: 'fulfilled'; + value: AccessCheckResult; +} | { + status: 'rejected'; + error: Error; }; // @public (undocumented) @@ -69,6 +89,7 @@ export type AccessRequirement = { address?: Address; id?: string; minAmount?: string; + standard?: 'ERC721' | 'ERC1155'; }; // @public @@ -78,9 +99,7 @@ export type AccessRule = AccessCheckRule | TokenBalanceAtLeastRule | HasRoleRule export class AccessService { // Warning: (ae-forgotten-export) The symbol "HttpClient" needs to be exported by the entry point index.d.ts // Warning: (ae-forgotten-export) The symbol "DiscrepancyHookPayload" needs to be exported by the entry point index.d.ts - constructor(http: HttpClient, validateResponses?: boolean, contracts?: ContractClient | undefined, onDiscrepancy?: ((payload: DiscrepancyHookPayload) => void | Promise) | undefined); - // (undocumented) - checkAccess(params: AccessCheckParams): Promise; + constructor(http: HttpClient, validateResponses?: boolean, contracts?: ContractClient | undefined, onDiscrepancy?: ((payload: DiscrepancyHookPayload) => void | Promise) | undefined, verifySignedResponses?: boolean, trustedSignerAddress?: string | undefined, strictAddressChecksum?: boolean); // (undocumented) checkAccess(params: AccessCheckParams, options: RequestOptions & { includeMeta: true; @@ -88,10 +107,12 @@ export class AccessService { data: AccessCheckResult; meta: ResponseMetadata; }>; + // (undocumented) + checkAccess(params: AccessCheckParams, options?: RequestOptions): Promise; checkAccessBatch(items: AccessCheckParams[], options?: AccessCheckBatchOptions & RequestOptions): Promise; - checkAccessVerified(params: AccessCheckParams, options: VerifiedAccessCheckOptions): Promise; // (undocumented) - checkRoleAccess(params: RoleAccessCheckParams): Promise; + checkAccessBatch(params: AccessCheckBatchByResourceParams, options?: AccessCheckBatchOptions & RequestOptions): Promise; + checkAccessVerified(params: AccessCheckParams, options: VerifiedAccessCheckOptions): Promise; // (undocumented) checkRoleAccess(params: RoleAccessCheckParams, options: RequestOptions & { includeMeta: true; @@ -99,6 +120,8 @@ export class AccessService { data: boolean; meta: ResponseMetadata; }>; + // (undocumented) + checkRoleAccess(params: RoleAccessCheckParams, options?: RequestOptions): Promise; } // @public @@ -154,8 +177,13 @@ export type AndRule = { // @public export const areAddressesEqual: (addr1: string, addr2: string) => boolean; +// Warning: (ae-forgotten-export) The symbol "ExplainingValidator" needs to be exported by the entry point index.d.ts +// +// @public +export function assertValidRequest(value: unknown, guard: ((value: unknown) => value is T) & Partial>, typeName: string, context?: RequestValidationContext): T; + // @public -export function assertValidResponse(value: unknown, guard: (value: unknown) => value is T, typeName: string): T; +export function assertValidResponse(value: unknown, guard: ((value: unknown) => value is T) & Partial>, typeName: string, context?: ResponseValidationContext): T; // @public export const BALANCE_OF_SELECTOR = "0x70a08231"; @@ -179,6 +207,9 @@ export type BlockTag = number | 'safe' | 'finalized'; // @public export const buildFunctionSignature: (abi: AbiFunction) => string; +// @public +export function buildGuildRoleDelegationTypedData(domain: EIP712Domain, delegation: GuildRoleDelegation): EIP712TypedData; + // @public export interface CacheAdapter { clear(): Promise; @@ -218,6 +249,30 @@ export type ChainConfig = { // @public (undocumented) export function connectWallet(provider: EIP1193Provider): Promise; +// @public +export type ConsensusMismatchDetails = { + totalProviders: number; + successfulCount: number; + failedCount: number; + quorum: number; + groups: ConsensusMismatchGroup[]; + failures: ConsensusMismatchFailure[]; +}; + +// @public +export type ConsensusMismatchFailure = { + url: string; + code: string; + message: string; +}; + +// @public +export type ConsensusMismatchGroup = { + value: string; + urls: string[]; + count: number; +}; + // @public (undocumented) export class ContractClient { constructor(config: GuildPassClientConfig, http?: HttpClient); @@ -247,6 +302,19 @@ export interface ContractProvider { ethCall(request: EthCallRequest, options?: RequestOptions): Promise; } +// @public +export type ContractReadConsensus = { + providers: string[]; + minProviders: number; +}; + +// @public +export function createMiddleware(name: string, handlers: { + onRequest?: (payload: RequestMiddlewarePayload) => void | Promise; + onResponse?: (payload: ResponseMiddlewarePayload) => void | Promise; + onError?: (payload: ErrorMiddlewarePayload) => void | Promise; +}): Middleware; + // @public (undocumented) export const DECIMALS_SELECTOR = "0x313ce567"; @@ -278,6 +346,57 @@ export interface EIP1193Provider { }): Promise; } +// @public +export interface EIP712Domain { + // (undocumented) + chainId?: number | bigint; + // (undocumented) + name?: string; + // (undocumented) + salt?: string; + // (undocumented) + verifyingContract?: string; + // (undocumented) + version?: string; +} + +// @public (undocumented) +export type EIP712Message = Record; + +// @public +export interface EIP712TypedData { + // (undocumented) + domain: EIP712Domain; + // (undocumented) + message: EIP712Message; + // (undocumented) + primaryType: string; + // (undocumented) + types: EIP712Types; +} + +// @public +export interface EIP712TypeProperty { + // (undocumented) + name: string; + // (undocumented) + type: string; +} + +// @public +export type EIP712Types = Record; + +// @public +export type EIP712Value = unknown; + +// @public +export interface EIP712VerifyResult { + code?: string; + error?: string; + signer?: string; + success: boolean; +} + // Warning: (ae-forgotten-export) The symbol "AbiParameter_2" needs to be exported by the entry point index.d.ts // // @public @@ -298,6 +417,9 @@ export const encodeInterfaceId: (interfaceId: string) => string; // @public export const encodePathSegment: (segment: string) => string; +// @public +export function encodeType(primaryType: string, types: EIP712Types): string; + // @public (undocumented) export const encodeUint256Argument: (value: string, label?: string) => string; @@ -345,6 +467,14 @@ export type ErrorHookPayload = RequestHookPayload & { durationMs: number; }; +// @public +export interface ErrorMiddlewarePayload { + durationMs?: number; + error: Error; + // (undocumented) + request: RequestMiddlewarePayload; +} + // @public export type EthCallRequest = { to: string; @@ -357,6 +487,13 @@ export function evaluateRule(client: RuleEvaluationClient, rule: AccessRule, con // @public (undocumented) export type FetchLike = (input: string | URL | Request, init?: RequestInit) => Promise; +// @public (undocumented) +export class FetchTransport implements HttpTransport { + constructor(fetchFn?: FetchLike | undefined); + // (undocumented) + execute(request: TransportRequest): Promise; +} + // @public export const formatIsoDate: (date: string | number | Date) => string; @@ -415,6 +552,9 @@ export type Guild = { chainId: number; }; +// @public +export const GUILD_ROLE_DELEGATION_TYPES: EIP712Types; + // @public (undocumented) export type GuildConfig = { id: string; @@ -441,6 +581,29 @@ export type GuildOwnersBatchParams = { chunkConcurrency?: number; }; +// @public +export class GuildPassApiError extends GuildPassError { + constructor(message: string, code: GuildPassErrorCode, status: number, details?: any); + static fromHttpError(status: number, details?: any, options?: { + retryAfterMs?: number | null; + }): GuildPassApiError; +} + +// @public +export class GuildPassAuthenticationError extends GuildPassApiError { + constructor(message: string, details?: any); +} + +// @public +export class GuildPassAuthorizationError extends GuildPassApiError { + constructor(message: string, details?: any); +} + +// @public +export class GuildPassCancellationError extends GuildPassNetworkError { + constructor(message?: string, details?: any); +} + // @public (undocumented) export class GuildPassClient { constructor(config: GuildPassClientConfig); @@ -449,7 +612,7 @@ export class GuildPassClient { clearCache(): Promise; // (undocumented) readonly contracts: ContractClient; - getConfig(): Omit; + getConfig(): PublicClientConfig; // (undocumented) readonly guilds: GuildsService; invalidateGuildCache(guildId: string): Promise; @@ -460,6 +623,57 @@ export class GuildPassClient { readonly roles: RolesService; } +// @public (undocumented) +export class GuildPassClientBuilder { + constructor(apiUrl?: string); + // (undocumented) + build(): GuildPassClient; + // (undocumented) + withApiKey(apiKey: string): this; + // (undocumented) + withApiUrl(apiUrl: string): this; + // (undocumented) + withBatchStrategy(strategy: 'jsonrpc' | 'multicall3'): this; + // (undocumented) + withCache(cache: CacheAdapter, ttlMs?: number): this; + // (undocumented) + withChain(chainId: number, chainConfig: ChainConfig): this; + // (undocumented) + withClientMetadata(name: string, version: string): this; + // (undocumented) + withContractProvider(provider: ContractProvider): this; + // (undocumented) + withContractReadConsensus(consensus: ContractReadConsensus): this; + // (undocumented) + withDeduplication(enabled: boolean): this; + // (undocumented) + withFetch(fetchFn: FetchLike): this; + // (undocumented) + withHooks(hooks: HttpHooks): this; + // (undocumented) + withMiddleware(middleware: Middleware[]): this; + // Warning: (ae-forgotten-export) The symbol "RateLimitConfig" needs to be exported by the entry point index.d.ts + // + // (undocumented) + withRateLimit(rateLimit: RateLimitConfig): this; + // (undocumented) + withRetry(retry: RetryConfig): this; + // (undocumented) + withRpcUrl(rpcUrl: string): this; + // (undocumented) + withRpcUrls(rpcUrls: string[]): this; + // (undocumented) + withSignedResponses(enabled: boolean, trustedSignerAddress?: string): this; + // (undocumented) + withStrictAddressChecksum(enabled: boolean): this; + // (undocumented) + withStrictInterfaceChecking(enabled: boolean): this; + // (undocumented) + withTimeout(timeoutMs: number): this; + // (undocumented) + withTransport(transport: HttpTransport): this; +} + // @public (undocumented) export type GuildPassClientConfig = { apiUrl: string; @@ -473,19 +687,32 @@ export type GuildPassClientConfig = { chains?: Record; apiKey?: string; timeoutMs?: number; + defaultTimeoutMs?: number; retry?: RetryConfig; hooks?: HttpHooks; + middleware?: Middleware[]; fetch?: FetchLike; + transport?: HttpTransport; rateLimit?: RateLimitConfig; validateResponses?: boolean; + strictAddressChecksum?: boolean; cache?: CacheAdapter; cacheTtl?: number; + deduplication?: boolean; sendClientMetadata?: boolean; clientName?: string; clientVersion?: string; strictInterfaceChecking?: boolean; + verifySignedResponses?: boolean; + trustedSignerAddress?: string; + contractReadConsensus?: ContractReadConsensus; }; +// @public +export class GuildPassConfigError extends GuildPassError { + constructor(message: string, code?: GuildPassErrorCode, status?: number, details?: any); +} + // @public (undocumented) export class GuildPassError extends Error { constructor(message: string, code: GuildPassErrorCode, status?: number, details?: any); @@ -498,6 +725,14 @@ export class GuildPassError extends Error { requestMeta?: ResponseMetadata; // (undocumented) readonly status?: number; + toJSON(): { + name: string; + message: string; + code: GuildPassErrorCode; + status: number | undefined; + requestMeta: ResponseMetadata | undefined; + details: unknown; + }; } // @public (undocumented) @@ -508,6 +743,17 @@ export enum GuildPassErrorCode { CACHE_ERROR = "CACHE_ERROR", // (undocumented) CONFLICT = "CONFLICT", + CONSENSUS_MISMATCH = "CONSENSUS_MISMATCH", + // (undocumented) + EIP712_EXPIRED = "EIP712_EXPIRED", + // (undocumented) + EIP712_INVALID_SIGNATURE = "EIP712_INVALID_SIGNATURE", + // (undocumented) + EIP712_INVALID_TYPED_DATA = "EIP712_INVALID_TYPED_DATA", + // (undocumented) + EIP712_REPLAY_DETECTED = "EIP712_REPLAY_DETECTED", + // (undocumented) + EIP712_SIGNER_MISMATCH = "EIP712_SIGNER_MISMATCH", // (undocumented) HTTP_ERROR = "HTTP_ERROR", // (undocumented) @@ -545,7 +791,41 @@ export enum GuildPassErrorCode { // (undocumented) UNAUTHORISED = "UNAUTHORISED", // (undocumented) - UNKNOWN_ERROR = "UNKNOWN_ERROR" + UNKNOWN_ERROR = "UNKNOWN_ERROR", + // (undocumented) + UNVERIFIABLE_RESPONSE = "UNVERIFIABLE_RESPONSE" +} + +// @public +export class GuildPassNetworkError extends GuildPassError { + constructor(message: string, code?: GuildPassErrorCode, details?: any); +} + +// @public +export class GuildPassRateLimitError extends GuildPassApiError { + constructor(message: string, details?: any); + // (undocumented) + retryAfterMs?: number; +} + +// @public +export class GuildPassResponseValidationError extends GuildPassError { + constructor(message: string, code?: GuildPassErrorCode, status?: number, details?: any); +} + +// @public +export class GuildPassServerError extends GuildPassApiError { + constructor(message: string, status: number, details?: any); +} + +// @public +export class GuildPassTimeoutError extends GuildPassNetworkError { + constructor(message: string, details?: any); +} + +// @public +export class GuildPassValidationError extends GuildPassApiError { + constructor(message: string, code: GuildPassErrorCode, status: number, details?: any); } // @public (undocumented) @@ -556,27 +836,46 @@ export type GuildRole = { requirements?: AccessRequirement[]; }; +// @public +export interface GuildRoleDelegation { + delegate: string; + delegator: string; + expiry: bigint | number; + guildId: string; + nonce: bigint | number; + roleId: string; +} + // @public (undocumented) export class GuildsService { - constructor(http: HttpClient, validateResponses?: boolean); - getGuild(params: GetGuildParams): Promise; - // (undocumented) + constructor(http: HttpClient, validateResponses?: boolean, verifySignedResponses?: boolean, trustedSignerAddress?: string | undefined); getGuild(params: GetGuildParams, options: RequestOptions & { includeMeta: true; }): Promise<{ data: Guild; meta: ResponseMetadata; }>; - getGuildConfig(params: GetGuildParams): Promise; // (undocumented) + getGuild(params: GetGuildParams, options?: RequestOptions): Promise; getGuildConfig(params: GetGuildParams, options: RequestOptions & { includeMeta: true; }): Promise<{ data: GuildConfig; meta: ResponseMetadata; }>; + // (undocumented) + getGuildConfig(params: GetGuildParams, options?: RequestOptions): Promise; } +// @public +export function hashDomain(domain: EIP712Domain): Uint8Array; + +// @public +export function hashStruct(primaryType: string, data: EIP712Message, types: EIP712Types): Uint8Array; + +// @public +export function hashTypedData(typedData: EIP712TypedData): Uint8Array; + // @public (undocumented) export function hasInjectedWallet(): boolean; @@ -613,7 +912,9 @@ export const HEX_32_BYTES_LENGTH = 64; export type HttpClientConfig = { retry?: RetryConfig; hooks?: HttpHooks; + middleware?: Middleware[]; fetch?: FetchLike; + transport?: HttpTransport; metadata?: ClientMetadata; rateLimit?: RateLimitConfig; }; @@ -657,6 +958,12 @@ export type HttpResponse = { meta?: ResponseMeta; }; +// @public (undocumented) +export interface HttpTransport { + // (undocumented) + execute(request: TransportRequest): Promise; +} + // @public export class InMemoryCacheAdapter implements CacheAdapter { // (undocumented) @@ -689,12 +996,24 @@ export class InMemoryNonceStore implements NonceStore { // Warning: (ae-forgotten-export) The symbol "Validator" needs to be exported by the entry point index.d.ts // +// @public +export const isAccessCheckParams: Validator; + // @public export const isAccessCheckResult: Validator; // @public export const isChecksumAddress: (address: string) => boolean; +// @public +export const isGetGuildParams: Validator; + +// @public +export const isGetRolesParams: Validator; + +// @public +export const isGetUserRolesParams: Validator; + // @public export const isGuild: Validator; @@ -713,6 +1032,17 @@ export const isGuildRoleArray: Validator; // @public export const isMembership: Validator; +// @public +export const isMembershipParams: Validator; + +// @public +export const isRoleAccessCheckParams: Validator; + +// @public +export const isRoleCheckResult: Validator<{ + hasRole: boolean; +}>; + // @public export class JsonRpcContractProvider implements ContractProvider { constructor(http: HttpClient, rpcUrls: string | string[], hooks?: HttpHooks, chainId?: number); @@ -747,15 +1077,15 @@ export type MembershipParams = { // @public (undocumented) export class MembershipService { - constructor(http: HttpClient, validateResponses?: boolean); - getMembership(params: MembershipParams): Promise; - // (undocumented) + constructor(http: HttpClient, validateResponses?: boolean, strictAddressChecksum?: boolean); getMembership(params: MembershipParams, options: RequestOptions & { includeMeta: true; }): Promise<{ data: Membership; meta: ResponseMetadata; }>; + // (undocumented) + getMembership(params: MembershipParams, options?: RequestOptions): Promise; isMember(params: MembershipParams, options?: T): Promise; // @internal export function mergeRpcUrls(rpcUrl?: string, rpcUrls?: string[]): string[]; +// @public +export interface Middleware { + name: string; + onError?(payload: ErrorMiddlewarePayload): void | Promise; + onRequest?(payload: RequestMiddlewarePayload): void | Promise; + onResponse?(payload: ResponseMiddlewarePayload): void | Promise; +} + // @public export const MULTICALL3_ADDRESS = "0xcA11bde05977b3631167028862bE2a173976CA11"; @@ -830,6 +1168,9 @@ export type PaginatedResult = { // @public export function parseSiweMessage(raw: string): SiweParseResult; +// @public +export type PublicClientConfig = Omit; + // @public export type ReadContractParams = { contractAddress: string; @@ -856,6 +1197,14 @@ export type RequestHookPayload = { headers: Record; }; +// @public +export interface RequestMiddlewarePayload { + body?: unknown; + headers: Record; + method: HttpMethod; + path: string; +} + // @public (undocumented) export type RequestOptions = { timeoutMs?: number; @@ -863,8 +1212,15 @@ export type RequestOptions = { signal?: AbortSignal; includeMeta?: boolean; idempotencyKey?: string; + deduplicate?: boolean; + confirmations?: BlockTag; }; +// @public +export interface RequestValidationContext { + endpoint?: string; +} + // @public export const REQUIREMENT_TYPE_INTERFACE_IDS: Record; @@ -897,6 +1253,21 @@ export type ResponseMetadata = { durationMs: number; }; +// @public +export interface ResponseMiddlewarePayload { + data: unknown; + durationMs: number; + // (undocumented) + request: RequestMiddlewarePayload; + responseHeaders: Record; + status: number; +} + +// @public +export interface ResponseValidationContext { + endpoint?: string; +} + // @public (undocumented) export type RetryConfig = { maxRetries?: number; @@ -904,6 +1275,7 @@ export type RetryConfig = { maxDelayMs?: number; retryableStatuses?: number[]; allowMutatingRetry?: boolean; + jitter?: boolean; }; // @public (undocumented) @@ -922,7 +1294,7 @@ export type RoleRequirementParams = { // @public (undocumented) export class RolesService { - constructor(http: HttpClient, validateResponses?: boolean, access?: AccessService | undefined); + constructor(http: HttpClient, validateResponses?: boolean, access?: AccessService | undefined, strictAddressChecksum?: boolean); getRoles(params: GetRolesParams & ({ cursor: string; } | { @@ -1104,6 +1476,37 @@ export type TokenBalancesBatchParams = { chunkConcurrency?: number; }; +// @public (undocumented) +export interface TransportRequest { + // (undocumented) + body?: string; + // (undocumented) + headers: Record; + // (undocumented) + method: string; + // (undocumented) + signal?: AbortSignal; + // (undocumented) + url: string; +} + +// @public (undocumented) +export interface TransportResponse { + // (undocumented) + getHeader(name: string): string | null; + // (undocumented) + getHeaders(): Record; + // (undocumented) + json(): Promise; + // (undocumented) + ok: boolean; + // (undocumented) + status: number; +} + +// @public +export function typeHash(primaryType: string, types: EIP712Types): Uint8Array; + // @public export type UrlCapabilities = { supportsJsonRpcBatch: boolean; @@ -1132,6 +1535,11 @@ export const validateConfigField: (field: string, value: any, rules: { expectedType?: string; }) => void; +// Warning: (ae-internal-missing-underscore) The name "validateContractReadConsensus" should be prefixed with an underscore because the declaration is marked as @internal +// +// @internal +export function validateContractReadConsensus(consensus: ContractReadConsensus | undefined): void; + // @public export const validateGuildId: (guildId: string) => void; @@ -1168,16 +1576,28 @@ export type VerifiedAccessCheckResult = { discrepancyReason?: string; }; +// @public +export function verifyGuildRoleDelegationSignature(domain: EIP712Domain, delegation: GuildRoleDelegation, signature: string, options?: { + checkExpiry?: boolean; +}): EIP712VerifyResult; + +// @public +export function verifyGuildRoleDelegationWithReplayProtection(domain: EIP712Domain, delegation: GuildRoleDelegation, signature: string, nonceStore: NonceStore, options?: { + checkExpiry?: boolean; +}): Promise; + // @public export function verifySiweSignature(params: SiweVerifyParams): SiweVerifyResult; // @public export function verifySiweSignatureWithReplayProtection(params: SiweVerifyParams, nonceStore: NonceStore): Promise; +// @public +export function verifyTypedDataSignature(domain: EIP712Domain, types: EIP712Types, primaryType: string, message: EIP712Message, signature: string, expectedSigner: string): EIP712VerifyResult; + // Warnings were encountered during analysis: // -// dist/common-uwB90XDn.d.ts:79:5 - (ae-forgotten-export) The symbol "ClientMetadata" needs to be exported by the entry point index.d.ts -// dist/roles.types-BfOMglGu.d.ts:109:5 - (ae-forgotten-export) The symbol "RateLimitConfig" needs to be exported by the entry point index.d.ts +// dist/common-CabkBCAt.d.ts:224:5 - (ae-forgotten-export) The symbol "ClientMetadata" needs to be exported by the entry point index.d.ts // (No @packageDocumentation comment for this package) diff --git a/docs/architecture.md b/docs/architecture.md index c847097..9147ae6 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -300,6 +300,30 @@ The SDK includes a resilient caching layer that wraps service methods. - **Resilience**: Caching is non-blocking and failure-tolerant. Cache errors are isolated from the main request flow. - **Observability**: Developers can monitor cache health via lifecycle hooks. +**In-flight request coalescing.** When `deduplication` is enabled (the +default), concurrent calls with identical arguments share a single +underlying HTTP request instead of each issuing their own — see +`GuildPassClient.coalesce()`/`withCache()` in `src/client/GuildPassClient.ts`. +Two exceptions, both there to stop one caller's request from affecting an +unrelated caller's: + +- **A caller-supplied `signal` opts that call out of coalescing by + default.** If two callers share the same cache key but only one passes an + `AbortSignal`, they get independent requests — otherwise the signalled + caller aborting would reject the other caller too, who never asked to be + cancelled. Pass `deduplicate: true` explicitly to opt back in despite a + signal being present; `deduplicate: false` always opts out. +- **`checkAccessBatch` never coalesces its items with each other or with a + concurrent lone `checkAccess` call**, regardless of the `deduplication` + config — see the `neverCoalesce` proxy in + `GuildPassClient.buildCachedAccessService()`. Cache reads/writes still + happen per item; only in-flight sharing is skipped. + +`deduplicate` is a client-orchestration-only option — it is never present in +the object handed to the underlying service method or `HttpClient` (see +`stripDeduplicate()` in `GuildPassClient.ts`), so it can't leak into request +options a service forwards to the transport. + ## Data Flow 1. Developer initializes `GuildPassClient` with an optional `cache`. @@ -308,7 +332,10 @@ The SDK includes a resilient caching layer that wraps service methods. - The SDK attempts to retrieve the value from the `cache`. - If successful (cache hit), the value is returned immediately. - If a cache failure occurs, the SDK logs the error via hooks and proceeds to the network. -4. Service validates input using `src/utils/validation.ts`. +4. Service validates input — a structural schema check first (for the + subset of models covered so far, see + [`docs/serialization-validation.md`](./serialization-validation.md)), + then the field-level checks in `src/utils/validation.ts`. 5. Service calls `HttpClient` with the appropriate path and params. 6. `HttpClient` executes the fetch request. 7. If successful: diff --git a/docs/serialization-validation.md b/docs/serialization-validation.md new file mode 100644 index 0000000..8d51da7 --- /dev/null +++ b/docs/serialization-validation.md @@ -0,0 +1,211 @@ +# Request & Response Validation + +This document describes the pattern used to validate the shape of data that +crosses the boundary between the SDK and the GuildPass API: request +parameters going out, and response bodies coming back in. It exists for +contributors adding a new service method or model, not for SDK consumers. + +## Status + +This pattern is applied incrementally, model by model. As of this writing, +request-schema validation covers every HTTP-based domain service call — +the full set of `this.http.get(...)` call sites across +`AccessService` (`checkAccess`, `checkRoleAccess`), `MembershipService` +(`getMembership`), `RolesService` (`getRoles`, `getUserRoles`), and +`GuildsService` (`getGuild`, `getGuildConfig`) — pairing every request +model with the response guard that already existed for it: + +| Request guard (`requestGuards.ts`) | Response guard (`responseGuards.ts`) | Service method | +| --- | --- | --- | +| `isAccessCheckParams` | `isAccessCheckResult` | `AccessService.checkAccess` | +| `isRoleAccessCheckParams` | `isRoleCheckResult` | `AccessService.checkRoleAccess` | +| `isMembershipParams` | `isMembership` | `MembershipService.getMembership` | +| `isGetRolesParams` | `isGuildRoleArray` | `RolesService.getRoles` | +| `isGetUserRolesParams` | `isGuildRoleArray` | `RolesService.getUserRoles` | +| `isGetGuildParams` | `isGuild` | `GuildsService.getGuild` | +| `isGetGuildParams` | `isGuildConfig` | `GuildsService.getGuildConfig` | + +`RolesService.hasRole` needs no guard of its own — it delegates to +`AccessService.checkRoleAccess`, which already validates. + +**Out of scope**, deliberately: `ContractClient` / `src/contracts/` (reads +ABI-encoded data over raw JSON-RPC, not the GuildPass JSON API — see +`docs/architecture.md`), `src/siwe/` and `src/eip712/` (message parsing +with their own dedicated validation, not HTTP request/response models), +and the 3 `this.http.post(...)` call sites inside +`src/contracts/providers/jsonRpcProvider.ts` (raw JSON-RPC envelopes, not +domain models). Do not assume this pattern applies there without checking +first. + +There is **no external schema/validation dependency** (no Zod, Valibot, ajv, +etc.). Everything is built on the zero-dependency combinator DSL in +`src/validation/schema.ts`: `string()`, `nonEmptyString()`, `number()`, +`boolean()`, `address()`, `object()`, `array()`, `optional()`, `record()`. +This is a deliberate choice, not an oversight — see "Why no external +library" below. + +## The two halves + +### Response validation + +- **Guard**: a `Validator` predicate for the response type, defined in + `src/validation/responseGuards.ts` (e.g. `isAccessCheckResult`). Built by + composing the `schema.ts` combinators; the field list must mirror the + type it validates (`src/access/access.types.ts` for `AccessCheckResult`, + etc.) — these are two separate declarations today, not derived from one + another, so keep them in sync by hand when a type changes. +- **Enforcement**: `assertValidResponse(value, guard, typeName, { endpoint })` + from `src/validation/assertResponse.ts`, called inside the service method + after the HTTP response is parsed, right before it's returned to the + caller. +- **On by default**: gated behind `GuildPassClientConfig.validateResponses` + (default `true` as of the model pairs listed in "Status" above), threaded + into each `*Service` constructor. Set `validateResponses: false` to + restore the old unchecked behavior. Response guards must stay + **permissive by construction** for this default to be safe — see + "Unknown fields" below, and note that array fields validated with + `array()` (not `nonEmptyArray()`) unless the model genuinely requires at + least one element: `AccessCheckResult.requiredRoles`/`matchedRoles`, for + example, are legitimately empty for a public resource or a wallet with no + roles, and a guard requiring `nonEmptyArray()` there was a real bug found + and fixed while making validation the default. Similarly, `optional()` + accepts both `undefined` **and** `null` — a JSON API response can only + omit a key or send `null`, never a literal `undefined`, so a combinator + that only accepted `undefined` would reject the far more common shape of + a real absent field (this was also a real bug, fixed alongside the array + one). When adding a new guard, double-check every field against realistic + API responses, not just the TypeScript type shape, before relying on this + default. + +### Request validation + +- **Guard**: a `Validator` predicate for the request parameters type, in + `src/validation/requestGuards.ts` (e.g. `isAccessCheckParams`). These + guards are **structural only** — they check that required fields are + present and hold the right primitive type. They deliberately do **not** + duplicate semantic checks (Ethereum address format/checksum, ID length + limits, whitespace-only strings) that already live in + `src/utils/validation.ts` (`validateAddress`, `validateGuildId`, + `validateResourceId`, `validateRoleId`). +- **Enforcement**: `assertValidRequest(value, guard, typeName, { endpoint })` + from `src/validation/assertRequest.ts`, called as the **first** line of + the service method, before the params object is destructured and before + the existing field-level validators run. +- **Not opt-in**: unlike response validation, this runs unconditionally. + It's safe to make unconditional because it is provably a strict superset + check relative to nothing (there was no structural check before) and a + subset relative to the field validators that already run right after it + — for any input that was already accepted by the old code path, the new + guard also accepts it, because it only checks "is this the right + primitive type", which the semantic validators require anyway. It only + changes behavior for inputs that previously bypassed validation entirely, + e.g. `service.checkAccess(null)`, which used to throw a raw + `TypeError` from destructuring and now throws an actionable + `GuildPassConfigError`. + +**Ordering matters.** Keep request-schema validation *before* the +field-level validators, and keep the field-level validators unchanged. If +you're tempted to replace `validateAddress`/`validateGuildId`/etc. with a +single schema call, don't — those functions throw specific error codes +(`INVALID_ADDRESS` vs `INVALID_INPUT`) and enforce format/checksum/length +rules the structural schema does not, and tests assert on those exact +codes. + +## Adding a schema for a new model + +1. Add the response guard to `src/validation/responseGuards.ts`, matching + the model's type in its `*.types.ts` file field-for-field. +2. If the method takes params, add the request guard to + `src/validation/requestGuards.ts`, using only structural checks + (`nonEmptyString()`, `number()`, `boolean()`, `optional(...)`, + `array(...)`) — leave format/business-rule validation to + `src/utils/validation.ts` or a new function there if the field is new. +3. Wire `assertValidRequest(...)` as the first line of the service method, + and `assertValidResponse(...)` where the response is currently returned + (respecting the existing `validateResponses` flag for the response half + only — it's on by default, so double-check your new guard against + realistic responses, not just the type shape, before wiring it in). +4. Export both guards from `src/index.ts` if — and only if — the model they + validate is itself part of the public API (check `api-report/guildpass-sdk.api.md` + or run `pnpm api-report` after building to confirm the new exports are + intentional; `tests/api-report-diff.test.ts` fails the build if the + public surface changes unexpectedly). +5. Add tests: a guard-predicate test file (mirror + `tests/responseGuards.test.ts` / `tests/requestGuards.test.ts`), and, if + the model is worth a dedicated round-trip/compat check, follow the + pattern in `tests/serialization-compat.test.ts`. + +## Unknown fields in responses + +**Passthrough, always.** `object()` (the only combinator used by response +guards) validates the fields it's told about and ignores everything else — +it neither strips nor rejects extra keys. A server adding a new field to a +response is not a breaking change for SDK consumers: the guard still +passes, and the extra field rides along on the returned object exactly as +the API sent it, available to any consumer that reads it directly (even +though the SDK's TypeScript type doesn't declare it, so accessing it +requires a cast). + +`strictObject()` (same file) is the strict counterpart — it rejects any key +not in the shape. It exists for local/internal shapes where an unexpected +key is genuinely a bug (e.g. validating your own config), and must **not** +be used for a response guard. Using it there would mean any new field the +API team ships breaks every SDK consumer until they upgrade — exactly the +failure mode this whole layer exists to avoid. + +## Error shape + +Both `assertValidResponse` and `assertValidRequest` throw on mismatch and +never return a `Result`/discriminated union — this matches every other +locally- and remotely-detected error in the SDK (`GuildPassConfigError`, +`GuildPassApiError`, etc.), so adding schema validation to a new model +never changes a method's return type. + +- **Response mismatch** → `GuildPassResponseValidationError` + (`code: INVALID_RESPONSE`). +- **Request mismatch** → `GuildPassConfigError` (`code: INVALID_INPUT`) — + the same error class and code the existing field validators in + `src/utils/validation.ts` already use, so `instanceof GuildPassConfigError` + checks keep working for old and new validation failures alike. + +Both attach structured `details` and a message built from three pieces: + +``` + + (): : expected , got +``` + +For example: + +``` +Invalid AccessCheckParams request parameters (GET /access/check): AccessCheckParams.resourceId: expected a non-empty string, got string "" +``` + +`error.details.mismatch` carries the same field-path explanation +programmatically; `error.details.received` carries the raw value that +failed — with one exception: on the request side, any top-level field whose +name matches a small sensitive-looking list (`apiKey`, `secret`, +`privateKey`, `password`, `token`, `signature`, case-insensitive) is +replaced with `'[REDACTED]'` before being attached, so a validation error +can never leak a credential into logs or error-reporting tools. Response +payloads are not redacted this way — they come from the server, not from +caller-supplied secrets. + +## Why no external library + +At audit time the SDK had zero runtime dependencies (`js-sha3` only) and a +21 KB budget for the main entrypoint (`bundle-size-budget.json`), enforced +by `pnpm size` in CI. It also ships and tests against three runtimes (Node, +browser via jsdom, edge via `edge-runtime`) — see "Runtime Compatibility" in +`docs/architecture.md`. Adding Zod, Valibot, or ajv would mean growing every +consumer's bundle (even tree-shaken, a general-purpose schema library costs +more than a handful of combinator functions built for exactly this SDK's +shapes) and re-verifying its behavior across all three runtimes. The +existing `schema.ts` DSL already provides object/array/optional +composition, field-path error explanations, and TypeScript inference +(`InferShape`) — the properties that would motivate reaching for an +external library — at effectively zero bundle cost. Revisit this if a +future model needs something the DSL genuinely can't express (e.g. +discriminated unions across many variants); that's a real gap today +(`schema.ts` has no `union()`/`literal()` combinator yet) and should be +solved by extending the DSL first, before reaching for a dependency. diff --git a/scripts/check-bundle-size.mjs b/scripts/check-bundle-size.mjs index 818061d..8a04e65 100644 --- a/scripts/check-bundle-size.mjs +++ b/scripts/check-bundle-size.mjs @@ -32,7 +32,11 @@ async function checkSize() { format: 'esm', write: false, platform: 'browser', - external: ['node:crypto'], + // Match the real build (tsup treats peer deps as external): without + // this, esbuild bundles all of `ethers`/`viem` into the measurement, + // which both crashes on `merkleTree.ts`'s deep `ethers/lib/utils` + // import and, once resolved, wildly overstates every entry's size. + external: ['node:crypto', 'ethers', 'viem'], }); const buffer = Buffer.from(result.outputFiles[0].contents); diff --git a/src/access/access.service.ts b/src/access/access.service.ts index 13d485e..fe190bd 100644 --- a/src/access/access.service.ts +++ b/src/access/access.service.ts @@ -7,7 +7,9 @@ import { } from '../utils/validation'; import { normaliseAddress } from '../utils/address'; import { assertValidResponse } from '../validation/assertResponse'; +import { assertValidRequest } from '../validation/assertRequest'; import { isAccessCheckResult, isRoleCheckResult } from '../validation/responseGuards'; +import { isAccessCheckParams, isRoleAccessCheckParams } from '../validation/requestGuards'; import type { RequestOptions } from '../types/common'; import { verifySignedPayload, SignedEnvelope } from '../security'; import type { ResponseMetadata, DiscrepancyHookPayload } from '../http/http.types'; @@ -38,9 +40,10 @@ export class AccessService { private readonly strictAddressChecksum = false, ) {} - public async checkAccess(params: AccessCheckParams): Promise; public async checkAccess(params: AccessCheckParams, options: RequestOptions & { includeMeta: true }): Promise<{ data: AccessCheckResult; meta: ResponseMetadata }>; + public async checkAccess(params: AccessCheckParams, options?: RequestOptions): Promise; public async checkAccess(params: AccessCheckParams, options?: RequestOptions): Promise { + assertValidRequest(params, isAccessCheckParams, 'AccessCheckParams', { endpoint: 'GET /access/check' }); const { walletAddress, guildId, resourceId } = params; validateAddress(walletAddress, { strict: this.strictAddressChecksum }); @@ -298,17 +301,19 @@ export class AccessService { } } - public async checkRoleAccess( - params: RoleAccessCheckParams, - ): Promise; public async checkRoleAccess( params: RoleAccessCheckParams, options: RequestOptions & { includeMeta: true }, ): Promise<{ data: boolean; meta: ResponseMetadata }>; + public async checkRoleAccess( + params: RoleAccessCheckParams, + options?: RequestOptions, + ): Promise; public async checkRoleAccess( params: RoleAccessCheckParams, options?: RequestOptions, ): Promise { + assertValidRequest(params, isRoleAccessCheckParams, 'RoleAccessCheckParams', { endpoint: 'GET /access/role-check' }); // GuildPass SDK: Local block-scoped constant reference. const { walletAddress, guildId, roleId } = params; diff --git a/src/client/GuildPassClient.ts b/src/client/GuildPassClient.ts index 82c47cd..d10bd92 100644 --- a/src/client/GuildPassClient.ts +++ b/src/client/GuildPassClient.ts @@ -20,11 +20,10 @@ import { CacheAdapter } from '../cache/cache.types'; import { normaliseAddress } from '../utils/address'; import { validateAddress } from '../utils/validation'; import { encodePathSegment } from '../utils/formatting'; -import type { AccessCheckParams, AccessCheckResult, RoleAccessCheckParams, AccessCheckBatchOptions, AccessCheckBatchResult, AccessCheckBatchByResourceParams, AccessCheckBatchByResourceResult } from '../access/access.types'; -import type { MembershipParams, Membership } from '../membership/membership.types'; -import type { GetRolesParams, GetUserRolesParams, GuildRole, HasRoleParams } from '../roles/roles.types'; -import type { GetGuildParams, Guild, GuildConfig } from '../guilds/guilds.types'; -import type { RequestOptions } from '../types/common'; +import type { AccessCheckParams, RoleAccessCheckParams, AccessCheckBatchOptions, AccessCheckBatchResult, AccessCheckBatchByResourceParams, AccessCheckBatchByResourceResult } from '../access/access.types'; +import type { MembershipParams } from '../membership/membership.types'; +import type { GetRolesParams, GetUserRolesParams, HasRoleParams } from '../roles/roles.types'; +import type { GetGuildParams } from '../guilds/guilds.types'; /** * The main GuildPass SDK this. @@ -60,6 +59,19 @@ const buildCacheKey = (...parts: string[]): string => { return parts.map((part) => encodePathSegment(part)).join(':'); }; +/** + * `deduplicate` only means something to this client's own coalescing + * decision (see `withCache`) — it is never a real HTTP request option, so it + * must not be forwarded to the underlying service method (which spreads + * `options` verbatim into the transport call). + */ +function stripDeduplicate(options: T | undefined): T | undefined { + if (!options || !('deduplicate' in options)) return options; + // eslint-disable-next-line @typescript-eslint/no-unused-vars + const { deduplicate, ...rest } = options; + return rest as T; +} + // GuildPass SDK: Exported component definition. export class GuildPassClient { // GuildPass SDK: Class member structure property or constructor. @@ -118,7 +130,7 @@ export class GuildPassClient { }, ); - const validateResponses = this.config.validateResponses ?? false; + const validateResponses = this.config.validateResponses ?? true; const strictAddressChecksum = this.config.strictAddressChecksum ?? false; // IMPORTANT: Instantiate Contracts first so we can pass it to Access @@ -360,26 +372,41 @@ export class GuildPassClient { value: async (params: AccessCheckParams, options?: any): Promise => { const wallet = normaliseAddress(params.walletAddress); const key = buildCacheKey('access', 'checkAccess', params.guildId, params.resourceId, wallet); - return this.withCache(key, () => raw.checkAccess(params, options), accessCacheTtl, options?.deduplicate); + return this.withCache(key, () => raw.checkAccess(params, stripDeduplicate(options)), accessCacheTtl, options?.deduplicate ?? (options?.signal ? false : undefined)); }, }, checkRoleAccess: { value: async (params: RoleAccessCheckParams, options?: any): Promise => { const wallet = normaliseAddress(params.walletAddress); const key = buildCacheKey('access', 'checkRoleAccess', params.guildId, params.roleId, wallet); - return this.withCache(key, () => raw.checkRoleAccess(params, options), accessCacheTtl, options?.deduplicate); + return this.withCache(key, () => raw.checkRoleAccess(params, stripDeduplicate(options)), accessCacheTtl, options?.deduplicate ?? (options?.signal ? false : undefined)); + }, + }, + }); + + // A `checkAccess` variant used only by `checkAccessBatch` below: it reads + // and writes the cache exactly like `cached.checkAccess` above, but never + // coalesces with an in-flight request. Each batch item is independent — + // it must not be silently merged with a concurrent identical call from + // another batch (or a lone `checkAccess`) purely because they share a + // cache key, since that would let one caller's abort or failure affect + // an unrelated caller's request. + const neverCoalesce: AccessService = Object.create(raw, { + checkAccess: { + value: async (params: AccessCheckParams, options?: any): Promise => { + const wallet = normaliseAddress(params.walletAddress); + const key = buildCacheKey('access', 'checkAccess', params.guildId, params.resourceId, wallet); + return this.withCache(key, () => raw.checkAccess(params, options), accessCacheTtl, false); }, }, }); Object.defineProperty(cached, 'checkAccessBatch', { - // Bind to the cached proxy so each per-item check flows through the - // cached checkAccess above instead of bypassing the cache layer. value: async ( input: AccessCheckParams[] | AccessCheckBatchByResourceParams, options?: AccessCheckBatchOptions, ): Promise => - raw.checkAccessBatch.call(cached, input as any, options), + raw.checkAccessBatch.call(neverCoalesce, input as any, options), }); return cached; @@ -391,7 +418,7 @@ export class GuildPassClient { value: async (params: MembershipParams, options?: any): Promise => { const wallet = normaliseAddress(params.walletAddress); const key = buildCacheKey('membership', 'getMembership', params.guildId, wallet); - return this.withCache(key, () => raw.getMembership(params, options), undefined, options?.deduplicate); + return this.withCache(key, () => raw.getMembership(params, stripDeduplicate(options)), undefined, options?.deduplicate ?? (options?.signal ? false : undefined)); }, }, isMember: { @@ -426,7 +453,7 @@ export class GuildPassClient { getRoles: { value: async (params: GetRolesParams, options?: any): Promise => { const key = this.buildRolesCacheKey('getRoles', [params.guildId], params.cursor, params.limit); - return this.withCache(key, () => raw.getRoles(params, options), undefined, options?.deduplicate); + return this.withCache(key, () => raw.getRoles(params, stripDeduplicate(options)), undefined, options?.deduplicate ?? (options?.signal ? false : undefined)); }, }, getUserRoles: { @@ -438,14 +465,14 @@ export class GuildPassClient { params.cursor, params.limit, ); - return this.withCache(key, () => raw.getUserRoles(params, options), undefined, options?.deduplicate); + return this.withCache(key, () => raw.getUserRoles(params, stripDeduplicate(options)), undefined, options?.deduplicate ?? (options?.signal ? false : undefined)); }, }, hasRole: { value: async (params: HasRoleParams, options?: any): Promise => { const wallet = normaliseAddress(params.walletAddress); const key = buildCacheKey('access', 'checkRoleAccess', params.guildId, params.roleId, wallet); - return this.withCache(key, () => raw.hasRole(params, options), accessCacheTtl, options?.deduplicate); + return this.withCache(key, () => raw.hasRole(params, stripDeduplicate(options)), accessCacheTtl, options?.deduplicate ?? (options?.signal ? false : undefined)); }, }, }); @@ -456,13 +483,13 @@ export class GuildPassClient { getGuild: { value: async (params: GetGuildParams, options?: any): Promise => { const key = buildCacheKey('guilds', 'getGuild', params.guildId); - return this.withCache(key, () => raw.getGuild(params, options), undefined, options?.deduplicate); + return this.withCache(key, () => raw.getGuild(params, stripDeduplicate(options)), undefined, options?.deduplicate ?? (options?.signal ? false : undefined)); }, }, getGuildConfig: { value: async (params: GetGuildParams, options?: any): Promise => { const key = buildCacheKey('guilds', 'getGuildConfig', params.guildId); - return this.withCache(key, () => raw.getGuildConfig(params, options), undefined, options?.deduplicate); + return this.withCache(key, () => raw.getGuildConfig(params, stripDeduplicate(options)), undefined, options?.deduplicate ?? (options?.signal ? false : undefined)); }, }, }); diff --git a/src/client/GuildPassClientBuilder.ts b/src/client/GuildPassClientBuilder.ts index 95b7b26..425e278 100644 --- a/src/client/GuildPassClientBuilder.ts +++ b/src/client/GuildPassClientBuilder.ts @@ -3,7 +3,8 @@ import { GuildPassClient } from './GuildPassClient'; import type { HttpTransport } from '../network/transport.types'; import type { CacheAdapter } from '../cache/cache.types'; import type { ContractProvider } from '../contracts/providers/provider.types'; -import type { FetchLike, HttpHooks, Middleware, RateLimitConfig, RetryConfig } from '../http/http.types'; +import type { FetchLike, HttpHooks, RateLimitConfig, RetryConfig } from '../http/http.types'; +import type { Middleware } from '../middleware/middleware.types'; import type { ChainConfig, ContractReadConsensus } from '../contracts/contract.types'; export class GuildPassClientBuilder { diff --git a/src/config/defaultConfig.ts b/src/config/defaultConfig.ts index 68f1adf..7fef2f2 100644 --- a/src/config/defaultConfig.ts +++ b/src/config/defaultConfig.ts @@ -6,7 +6,11 @@ export const DEFAULT_CONFIG: Partial = { apiUrl: 'https://api.guildpass.xyz', chainId: 1, // Ethereum Mainnet timeoutMs: 10000, // 10 seconds - validateResponses: false, + // validateResponses is intentionally NOT set here: its default (true) + // lives solely in GuildPassClient's constructor (`?? true`), so a value + // set here would win over that fallback via the `??` operator and the + // two defaults could silently drift out of sync — exactly what happened + // before this comment was added. strictAddressChecksum: false, // GuildPass SDK: End of logic containment structure block. }; diff --git a/src/config/sdkConfig.ts b/src/config/sdkConfig.ts index e63ba40..9bf1562 100644 --- a/src/config/sdkConfig.ts +++ b/src/config/sdkConfig.ts @@ -53,6 +53,19 @@ export type GuildPassClientConfig = { fetch?: FetchLike; transport?: import('../network/transport.types').HttpTransport; rateLimit?: RateLimitConfig; + /** + * Validates every API response against its expected shape before + * returning it to the caller — see `docs/serialization-validation.md`. + * Unknown/extra fields are always passed through untouched; only missing + * required fields or wrong primitive types fail validation. On mismatch, + * throws `GuildPassResponseValidationError` (code `INVALID_RESPONSE`) + * naming the offending field, endpoint, and what was received. + * + * Set to `false` to restore the pre-validation behavior of returning + * whatever the server sent, unchecked. + * + * @default true + */ validateResponses?: boolean; /** Enforces EIP-55 checksums for all addresses accepted by the SDK. @default false */ strictAddressChecksum?: boolean; diff --git a/src/contracts/contractHelpers.ts b/src/contracts/contractHelpers.ts index e8db996..c53abbf 100644 --- a/src/contracts/contractHelpers.ts +++ b/src/contracts/contractHelpers.ts @@ -534,7 +534,7 @@ export const validateAccessRequirement = async ( export async function resolveRootOnChain( contractAddress: string, - provider: ethers.providers.Provider + provider: ethers.Provider ): Promise { const contract = new ethers.Contract( contractAddress, @@ -553,7 +553,8 @@ export async function resolveRootOnChain( isActive: isActive as boolean, }; } catch (error) { - throw new GuildPassNetworkError(`Failed to resolve root from contract: ${error.message}`, GuildPassErrorCode.HTTP_ERROR, error); + const message = error instanceof Error ? error.message : String(error); + throw new GuildPassNetworkError(`Failed to resolve root from contract: ${message}`, GuildPassErrorCode.HTTP_ERROR, error); } } @@ -581,7 +582,7 @@ export async function resolveCurrentRoot( options?: { rootSource?: 'onchain' | 'api'; contractAddress?: string; - provider?: ethers.providers.Provider; + provider?: ethers.Provider; apiBaseUrl?: string; } ): Promise { diff --git a/src/contracts/providers/jsonRpcProvider.ts b/src/contracts/providers/jsonRpcProvider.ts index cfd8df2..f3c2e36 100644 --- a/src/contracts/providers/jsonRpcProvider.ts +++ b/src/contracts/providers/jsonRpcProvider.ts @@ -380,7 +380,7 @@ export class JsonRpcContractProvider implements ContractProvider { // --------------------------------------------------------------------------- public async ethCall(request: EthCallRequest, options?: RequestOptions): Promise { - const blockTag = await this.resolveBlockTag((options as any)?.confirmations, options); + const blockTag = await this.resolveBlockTag(options?.confirmations, options); let lastError: unknown; for (let i = 0; i < this.rpcUrls.length; i++) { @@ -409,7 +409,7 @@ export class JsonRpcContractProvider implements ContractProvider { requests: EthCallRequest[], options?: RequestOptions, ): Promise { - const blockTag = await this.resolveBlockTag((options as any)?.confirmations, options); + const blockTag = await this.resolveBlockTag(options?.confirmations, options); let lastError: unknown; for (let i = 0; i < this.rpcUrls.length; i++) { diff --git a/src/contracts/providers/webSocketProvider.ts b/src/contracts/providers/webSocketProvider.ts index 20ea88a..882a785 100644 --- a/src/contracts/providers/webSocketProvider.ts +++ b/src/contracts/providers/webSocketProvider.ts @@ -272,7 +272,7 @@ export class WebSocketContractProvider implements SubscribableContractProvider { subs.add(subId); this.contractSubs.set(normalizedAddress, subs); }, - reject: (err: Error) => { + reject: (_err: Error) => { clearTimeout(timer); this.pending.delete(id); this.unconfirmedSubs.delete(id); diff --git a/src/guilds/guilds.service.ts b/src/guilds/guilds.service.ts index a636c6f..f0cdbb3 100644 --- a/src/guilds/guilds.service.ts +++ b/src/guilds/guilds.service.ts @@ -4,7 +4,9 @@ import { HttpClient } from '../http/httpClient'; import { validateGuildId } from '../utils/validation'; import { encodePathSegment } from '../utils/formatting'; import { assertValidResponse } from '../validation/assertResponse'; +import { assertValidRequest } from '../validation/assertRequest'; import { isGuild, isGuildConfig } from '../validation/responseGuards'; +import { isGetGuildParams } from '../validation/requestGuards'; import type { RequestOptions } from '../types/common'; import type { ResponseMetadata } from '../http/http.types'; import { verifySignedPayload, SignedEnvelope } from '../security'; @@ -27,9 +29,10 @@ export class GuildsService { * Fetches basic guild information. */ // GuildPass SDK: Class member structure property or constructor. - public async getGuild(params: GetGuildParams): Promise; public async getGuild(params: GetGuildParams, options: RequestOptions & { includeMeta: true }): Promise<{ data: Guild; meta: ResponseMetadata }>; + public async getGuild(params: GetGuildParams, options?: RequestOptions): Promise; public async getGuild(params: GetGuildParams, options?: RequestOptions): Promise { + assertValidRequest(params, isGetGuildParams, 'GetGuildParams', { endpoint: 'GET /guilds/:id' }); // GuildPass SDK: Local block-scoped constant reference. const { guildId } = params; validateGuildId(guildId); @@ -65,17 +68,19 @@ export class GuildsService { * Fetches full guild configuration including theme and social links. */ // GuildPass SDK: Class member structure property or constructor. - public async getGuildConfig( - params: GetGuildParams, - ): Promise; public async getGuildConfig( params: GetGuildParams, options: RequestOptions & { includeMeta: true }, ): Promise<{ data: GuildConfig; meta: ResponseMetadata }>; + public async getGuildConfig( + params: GetGuildParams, + options?: RequestOptions, + ): Promise; public async getGuildConfig( params: GetGuildParams, options?: RequestOptions, ): Promise { + assertValidRequest(params, isGetGuildParams, 'GetGuildParams', { endpoint: 'GET /guilds/:id/config' }); // GuildPass SDK: Define internal reference identifier. const { guildId } = params; validateGuildId(guildId); diff --git a/src/http/http.types.ts b/src/http/http.types.ts index 1b95ebd..1e0f9f5 100644 --- a/src/http/http.types.ts +++ b/src/http/http.types.ts @@ -1,8 +1,5 @@ -import { GuildPassError } from '../errors/GuildPassError'; import type { AccessCheckParams, AccessCheckResult } from '../access/access.types'; -import type { AccessRequirement } from '../types/common'; -import { GuildPassErrorCode } from '../errors/errorCodes'; -import type { ResponseMeta } from '../types/common'; +import type { AccessRequirement, ResponseMeta } from '../types/common'; import type { Middleware } from '../middleware/middleware.types'; export type HttpMethod = 'GET' | 'POST' | 'PUT' | 'DELETE' | 'PATCH'; diff --git a/src/http/httpClient.ts b/src/http/httpClient.ts index a2c2438..5a544c5 100644 --- a/src/http/httpClient.ts +++ b/src/http/httpClient.ts @@ -12,7 +12,7 @@ import type { ResponseMeta } from '../types/common'; import type { ClientMetadata, FetchLike, HttpClientConfig, HttpHooks, HttpRequestOptions, HttpResponse, RequestHookPayload, ResponseMetadata, RetryConfig } from './http.types'; import { TokenBucket } from './tokenBucket'; import { runRequestPipeline, runResponsePipeline, runErrorPipeline } from '../middleware/middleware.pipeline'; -import type { RequestMiddlewarePayload, ResponseMiddlewarePayload, ErrorMiddlewarePayload, Middleware } from '../middleware/middleware.types'; +import type { RequestMiddlewarePayload, Middleware } from '../middleware/middleware.types'; import type { HttpTransport, TransportResponse } from '../network/transport.types'; import { FetchTransport } from '../network/fetchTransport'; @@ -20,13 +20,6 @@ const IDEMPOTENT_METHODS = new Set(['GET', 'HEAD', 'OPTIONS', 'PUT', 'DELETE']); const DEFAULT_RETRYABLE_STATUSES = [429, 500, 502, 503, 504]; const SENSITIVE_HEADERS = new Set(['authorization', 'x-api-key', 'cookie', 'set-cookie']); -const META_HEADERS: Array<{ headerName: string; metaKey: keyof ResponseMeta }> = [ - { headerName: 'x-request-id', metaKey: 'requestId' }, - { headerName: 'x-correlation-id', metaKey: 'correlationId' }, - { headerName: 'traceparent', metaKey: 'traceparent' }, - { headerName: 'x-trace-id', metaKey: 'traceId' }, -]; - function extractResponseMeta(response: Response | TransportResponse, durationMs: number): ResponseMeta { const meta: ResponseMeta = { status: response.status, durationMs }; @@ -131,17 +124,6 @@ function computeBackoffMs(config: Required, attempt: number): numbe return Math.max(0, Math.round(base + jitter)); } -function getRetryAfterMs(headers: Headers): number | null { - if (!headers || typeof headers.get !== 'function') return null; - const header = headers.get('Retry-After'); - if (!header) return null; - const seconds = Number(header); - if (!isNaN(seconds)) return seconds * 1000; - const date = Date.parse(header); - if (!isNaN(date)) return Math.max(0, date - Date.now()); - return null; -} - function delay(ms: number, signal?: AbortSignal): Promise { return new Promise((resolve, reject) => { if (signal?.aborted) { @@ -207,7 +189,10 @@ async function parseErrorResponse(response: Response | TransportResponse): Promi } function extractMeta(response: HttpResponse, durationMs: number): ResponseMetadata { - const safeGet = (key: string) => response.headers?.get?.(key) ?? undefined; + // `response.headers` may be a real `Headers` instance (has `.get()`) or a + // plain `Record` (e.g. from `TransportResponse.getHeaders()`). + const headers = response.headers as unknown as (Headers & Record) | Record | undefined; + const safeGet = (key: string) => (typeof headers?.get === 'function' ? headers.get(key) : headers?.[key]) ?? undefined; return { requestId: safeGet('x-request-id'), correlationId: safeGet('x-correlation-id'), traceId: safeGet('traceparent'), status: response.status, durationMs }; } @@ -217,9 +202,9 @@ export class HttpClient { private readonly timeoutMs: number; private readonly globalRetry?: RetryConfig; private readonly hooks?: HttpHooks; - private readonly middleware: Middleware[]; + private readonly middleware!: Middleware[]; private readonly fetchTransport?: FetchLike; - private readonly transport: HttpTransport; + private readonly transport!: HttpTransport; private readonly metadata?: ClientMetadata; private readonly tokenBucket?: TokenBucket; @@ -337,7 +322,7 @@ export class HttpClient { } // ──────────────────────────────────────────────────────────────────── - if (signal?.aborted) throw new GuildPassNetworkError('Request cancelled by caller', GuildPassErrorCode.REQUEST_CANCELLED); + if (signal?.aborted) throw new GuildPassCancellationError(); const startTime = Date.now(); const hookPayload: RequestHookPayload = { method, path, headers: redactHeaders(requestHeaders) }; diff --git a/src/index.ts b/src/index.ts index de1066e..add8bf4 100644 --- a/src/index.ts +++ b/src/index.ts @@ -39,6 +39,8 @@ export * from './config/sdkConfig'; // Validation export * from './validation/responseGuards'; export * from './validation/assertResponse'; +export * from './validation/requestGuards'; +export * from './validation/assertRequest'; export * from './wallet/helpers'; // SIWE (Sign-In With Ethereum) diff --git a/src/membership/membership.service.ts b/src/membership/membership.service.ts index aff250f..31d13b9 100644 --- a/src/membership/membership.service.ts +++ b/src/membership/membership.service.ts @@ -4,7 +4,9 @@ import { HttpClient } from '../http/httpClient'; import { validateAddress, validateGuildId } from '../utils/validation'; import { normaliseAddress } from '../utils/address'; import { assertValidResponse } from '../validation/assertResponse'; +import { assertValidRequest } from '../validation/assertRequest'; import { isMembership } from '../validation/responseGuards'; +import { isMembershipParams } from '../validation/requestGuards'; import type { RequestOptions } from '../types/common'; import type { ResponseMetadata } from '../http/http.types'; // GuildPass SDK: Import external module dependencies. @@ -23,17 +25,19 @@ export class MembershipService { * Fetches wallet membership status for a specific guild. */ // GuildPass SDK: Class member structure property or constructor. - public async getMembership( - params: MembershipParams, - ): Promise; public async getMembership( params: MembershipParams, options: RequestOptions & { includeMeta: true }, ): Promise<{ data: Membership; meta: ResponseMetadata }>; + public async getMembership( + params: MembershipParams, + options?: RequestOptions, + ): Promise; public async getMembership( params: MembershipParams, options?: RequestOptions, ): Promise { + assertValidRequest(params, isMembershipParams, 'MembershipParams', { endpoint: 'GET /membership' }); // GuildPass SDK: Local block-scoped constant reference. const { walletAddress, guildId } = params; diff --git a/src/network/fetchTransport.ts b/src/network/fetchTransport.ts index 9571c6a..288c0c0 100644 --- a/src/network/fetchTransport.ts +++ b/src/network/fetchTransport.ts @@ -4,15 +4,20 @@ import { GuildPassConfigError } from '../errors/errorTypes'; import { GuildPassErrorCode } from '../errors/errorCodes'; export class FetchTransport implements HttpTransport { - constructor(private readonly fetchFn: FetchLike = globalThis.fetch) { + // No default here: resolving `globalThis.fetch` happens lazily in + // `execute()` instead of being captured once at construction time, so a + // `fetch` installed or replaced (e.g. polyfilled, or swapped in tests via + // `vi.stubGlobal`) after this transport is constructed is still picked up. + constructor(private readonly fetchFn?: FetchLike) { } public async execute(request: TransportRequest): Promise { - if (typeof this.fetchFn !== 'function') { + const fetchFn = this.fetchFn ?? globalThis.fetch; + if (typeof fetchFn !== 'function') { throw new GuildPassConfigError('A fetch-compatible transport is required.', GuildPassErrorCode.INVALID_CONFIG); } - const response = await this.fetchFn(request.url, { + const response = await fetchFn(request.url, { method: request.method, headers: request.headers, body: request.body, @@ -31,8 +36,8 @@ export class FetchTransport implements HttpTransport { response.headers.forEach((value, key) => { result[key] = value; }); - } else if (response.headers?.entries) { - for (const [key, value] of response.headers.entries()) { + } else if ((response.headers as any)?.entries) { + for (const [key, value] of (response.headers as any).entries()) { result[key] = value; } } diff --git a/src/roles/roles.service.ts b/src/roles/roles.service.ts index cd221df..4b5d543 100644 --- a/src/roles/roles.service.ts +++ b/src/roles/roles.service.ts @@ -6,7 +6,9 @@ import { validateAddress, validateGuildId } from '../utils/validation'; import { normaliseAddress } from '../utils/address'; import { encodePathSegment } from '../utils/formatting'; import { assertValidResponse } from '../validation/assertResponse'; +import { assertValidRequest } from '../validation/assertRequest'; import { isGuildRoleArray } from '../validation/responseGuards'; +import { isGetRolesParams, isGetUserRolesParams } from '../validation/requestGuards'; import type { RequestOptions } from '../types/common'; import type { ResponseMetadata } from '../http/http.types'; // GuildPass SDK: Pull in package or module bindings. @@ -33,6 +35,7 @@ export class RolesService { public async getRoles(params: GetRolesParams, options: RequestOptions & { includeMeta: true }): Promise<{ data: GuildRole[]; meta: ResponseMetadata }>; public async getRoles(params: GetRolesParams, options?: RequestOptions): Promise; public async getRoles(params: GetRolesParams, options?: RequestOptions): Promise { + assertValidRequest(params, isGetRolesParams, 'GetRolesParams', { endpoint: 'GET /guilds/:id/roles' }); const { guildId, cursor, limit } = params; validateGuildId(guildId); @@ -58,6 +61,7 @@ export class RolesService { public async getUserRoles(params: GetUserRolesParams, options: RequestOptions & { includeMeta: true }): Promise<{ data: GuildRole[]; meta: ResponseMetadata }>; public async getUserRoles(params: GetUserRolesParams, options?: RequestOptions): Promise; public async getUserRoles(params: GetUserRolesParams, options?: RequestOptions): Promise { + assertValidRequest(params, isGetUserRolesParams, 'GetUserRolesParams', { endpoint: 'GET /guilds/:id/members/:address/roles' }); const { walletAddress, guildId, cursor, limit } = params; validateAddress(walletAddress, { strict: this.strictAddressChecksum }); diff --git a/src/siwe/siwe.helpers.ts b/src/siwe/siwe.helpers.ts index 8727a67..7ad1942 100644 --- a/src/siwe/siwe.helpers.ts +++ b/src/siwe/siwe.helpers.ts @@ -8,7 +8,6 @@ * @module siwe */ -import { GuildPassError } from '../errors/GuildPassError'; import { GuildPassErrorCode } from '../errors/errorCodes'; import type { SiweMessage, @@ -23,9 +22,6 @@ import { ecRecover, publicKeyToAddress, hashPersonalMessage, - hexToBytes, - keccak256Bytes, - bigintToBytes32, } from '../crypto/secp256k1'; /** Maximum length of a raw SIWE message string (10 KB). */ @@ -439,7 +435,7 @@ export function generateSiweNonce(): string { // Uses a lazy dynamic import that bundlers for Edge/browser targets will // tree-shake away, so it never blocks Edge compatibility. if (isNodeEnvironment()) { - // eslint-disable-next-line @typescript-eslint/no-require-imports + // eslint-disable-next-line @typescript-eslint/no-var-requires const nodeCrypto = require('node:crypto') as typeof import('node:crypto'); return nodeCrypto.randomBytes(1)[0]; } diff --git a/src/testing/fixtures.ts b/src/testing/fixtures.ts index 477e939..673072f 100644 --- a/src/testing/fixtures.ts +++ b/src/testing/fixtures.ts @@ -1,4 +1,4 @@ -import type { AccessCheckResult, AccessCheckParams } from '../access/access.types'; +import type { AccessCheckResult } from '../access/access.types'; import type { Guild, GuildConfig } from '../guilds/guilds.types'; import type { GuildRole } from '../roles/roles.types'; import type { Membership } from '../membership/membership.types'; diff --git a/src/types/common.ts b/src/types/common.ts index 0c59731..a666fe6 100644 --- a/src/types/common.ts +++ b/src/types/common.ts @@ -42,6 +42,12 @@ export type RequestOptions = { * Only applies to idempotent read operations; mutations are never deduplicated. */ deduplicate?: boolean; + /** + * Requests a historical or confirmed read instead of the chain's `latest` + * state for contract read calls. See {@link BlockTag}. Ignored for + * non-contract (plain HTTP) requests. + */ + confirmations?: BlockTag; }; export type AccessRequirement = { diff --git a/src/utils/index.ts b/src/utils/index.ts index 37c2056..9597ff0 100644 --- a/src/utils/index.ts +++ b/src/utils/index.ts @@ -1 +1,2 @@ export * from './merkleTree'; +export * from './constantTime'; diff --git a/src/utils/merkleTree.ts b/src/utils/merkleTree.ts index b4eb808..cfb5542 100644 --- a/src/utils/merkleTree.ts +++ b/src/utils/merkleTree.ts @@ -1,5 +1,4 @@ -import { ethers } from 'ethers'; -import { keccak256 } from 'ethers/lib/utils'; +import { keccak256 } from 'ethers'; export interface MerkleProof { path: string[]; diff --git a/src/validation/assertRequest.ts b/src/validation/assertRequest.ts new file mode 100644 index 0000000..7ab56e7 --- /dev/null +++ b/src/validation/assertRequest.ts @@ -0,0 +1,65 @@ +import { GuildPassConfigError } from '../errors/errorTypes'; +import { GuildPassErrorCode } from '../errors/errorCodes'; +import type { ExplainingValidator } from './schema'; + +/** + * Optional context attached to a validation failure so the thrown error + * identifies which endpoint the malformed request was headed for. + */ +export interface RequestValidationContext { + /** e.g. `'GET /access/check'` */ + endpoint?: string; +} + +/** Field-name fragments that mark a request field as sensitive; matched case-insensitively. */ +const SENSITIVE_FIELD_NAMES = new Set(['apikey', 'secret', 'privatekey', 'password', 'token', 'signature']); + +/** Redacts top-level fields whose name looks sensitive before attaching a value to error details. */ +function redactSensitiveFields(value: unknown): unknown { + if (typeof value !== 'object' || value === null || Array.isArray(value)) return value; + const redacted: Record = {}; + for (const [key, val] of Object.entries(value as Record)) { + redacted[key] = SENSITIVE_FIELD_NAMES.has(key.toLowerCase()) ? '[REDACTED]' : val; + } + return redacted; +} + +/** + * Validates request parameters against a shape guard, throwing a + * `GuildPassConfigError` (code `INVALID_INPUT`) if they don't match — + * consistent with every other locally-detected input problem in the SDK + * (see `src/utils/validation.ts`). + * + * Intended to run *before* the request-specific field validators + * (`validateAddress`, `validateGuildId`, ...), so it only ever rejects + * shapes those miss (missing params object, wrong top-level type, + * `null`/`undefined`); it never rejects a value that would otherwise pass + * today's field-by-field checks. When the guard is an explaining + * validator (all guards built from the `schema.ts` combinators are), the + * error message names the specific field path that failed; when + * `context.endpoint` is given, the endpoint is named too. + */ +export function assertValidRequest( + value: unknown, + guard: ((value: unknown) => value is T) & Partial>, + typeName: string, + context?: RequestValidationContext, +): T { + if (!guard(value)) { + const mismatch = guard.explain ? guard.explain(value, typeName) : null; + let message = `Invalid ${typeName} request parameters`; + if (context?.endpoint) message += ` (${context.endpoint})`; + if (mismatch) message += `: ${mismatch}`; + throw new GuildPassConfigError( + message, + GuildPassErrorCode.INVALID_INPUT, + undefined, + { + endpoint: context?.endpoint, + mismatch: mismatch ?? undefined, + received: redactSensitiveFields(value), + }, + ); + } + return value; +} diff --git a/src/validation/requestGuards.ts b/src/validation/requestGuards.ts new file mode 100644 index 0000000..b3c22e2 --- /dev/null +++ b/src/validation/requestGuards.ts @@ -0,0 +1,75 @@ +/** + * Runtime shape guards for the SDK's request parameter models. + * + * Built on the same composable schema DSL as `responseGuards.ts` + * (`schema.ts`). These guards check *structural* shape only — required + * fields present, correct primitive types — and intentionally do not + * duplicate the semantic checks (address format/checksum, ID length + * limits, trimmed-empty detection) already enforced by + * `src/utils/validation.ts`. That module keeps running immediately after + * these guards for the fields it covers; this file exists to catch the + * shapes it can't (missing params object entirely, wrong top-level type, + * `null`/`undefined`), and as the colocated source of truth for a + * request model's shape. + */ + +import { object, nonEmptyString, number, string, optional, type Validator } from './schema'; +import type { AccessCheckParams, RoleAccessCheckParams } from '../access/access.types'; +import type { MembershipParams } from '../membership/membership.types'; +import type { GetRolesParams, GetUserRolesParams } from '../roles/roles.types'; +import type { GetGuildParams } from '../guilds/guilds.types'; + +/** + * Checks whether `value` conforms to the {@link AccessCheckParams} shape. + */ +export const isAccessCheckParams: Validator = object({ + walletAddress: nonEmptyString(), + guildId: nonEmptyString(), + resourceId: nonEmptyString(), +}); + +/** + * Checks whether `value` conforms to the {@link RoleAccessCheckParams} shape. + */ +export const isRoleAccessCheckParams: Validator = object({ + walletAddress: nonEmptyString(), + guildId: nonEmptyString(), + roleId: nonEmptyString(), +}); + +/** + * Checks whether `value` conforms to the {@link MembershipParams} shape. + */ +export const isMembershipParams: Validator = object({ + walletAddress: nonEmptyString(), + guildId: nonEmptyString(), +}); + +/** + * Checks whether `value` conforms to the {@link GetRolesParams} shape. + * `cursor`/`limit` are checked as plain `string`/`number` (not + * non-empty/positive) — pagination-token semantics belong to the service, + * not this structural guard. + */ +export const isGetRolesParams: Validator = object({ + guildId: nonEmptyString(), + cursor: optional(string()), + limit: optional(number()), +}); + +/** + * Checks whether `value` conforms to the {@link GetUserRolesParams} shape. + */ +export const isGetUserRolesParams: Validator = object({ + walletAddress: nonEmptyString(), + guildId: nonEmptyString(), + cursor: optional(string()), + limit: optional(number()), +}); + +/** + * Checks whether `value` conforms to the {@link GetGuildParams} shape. + */ +export const isGetGuildParams: Validator = object({ + guildId: nonEmptyString(), +}); diff --git a/src/validation/responseGuards.ts b/src/validation/responseGuards.ts index d546fe5..bdf4f15 100644 --- a/src/validation/responseGuards.ts +++ b/src/validation/responseGuards.ts @@ -18,7 +18,6 @@ import { number, optional, array, - nonEmptyArray, record, type Validator, } from './schema'; @@ -51,15 +50,18 @@ const isAccessRequirement: Validator = object({ * Checks whether `value` conforms to the {@link AccessCheckResult} shape. * * Validates all fields including nested array content (non-empty strings - * for `requiredRoles`/`matchedRoles`) and Ethereum address format. + * for each entry of `requiredRoles`/`matchedRoles`) and Ethereum address + * format. The arrays themselves may be empty — a public resource with no + * required roles, or a denial with no matched roles, are both valid, + * common results — only `array()`, not `nonEmptyArray()`, is used here. */ export const isAccessCheckResult: Validator = object({ hasAccess: boolean(), walletAddress: address(), guildId: nonEmptyString(), resourceId: nonEmptyString(), - requiredRoles: nonEmptyArray(nonEmptyString()), - matchedRoles: nonEmptyArray(nonEmptyString()), + requiredRoles: array(nonEmptyString()), + matchedRoles: array(nonEmptyString()), reason: optional(string()), }); diff --git a/src/validation/schema.ts b/src/validation/schema.ts index bda01a5..b181622 100644 --- a/src/validation/schema.ts +++ b/src/validation/schema.ts @@ -155,15 +155,21 @@ export function address(): ExplainingValidator { // --------------------------------------------------------------------------- /** - * Wraps an inner validator to also accept `undefined`. + * Wraps an inner validator to also accept `undefined` or `null`. * Useful for optional object fields. + * + * Both are treated as "absent": a JSON API response can only omit a key + * (giving `undefined` once accessed) or send it as `null` — it has no way + * to produce a literal `undefined`, so accepting only `undefined` here + * would reject the far more common real-world shape of an absent optional + * field. */ export function optional(inner: Validator): ExplainingValidator { return withExplain( (value: unknown): value is T | undefined => - value === undefined || inner(value), + value === undefined || value === null || inner(value), (value, path = '$') => - value === undefined ? null : explainInner(inner, value, path), + value === undefined || value === null ? null : explainInner(inner, value, path), ); } diff --git a/tests/access.service.test.ts b/tests/access.service.test.ts index 14b9712..a55fe61 100644 --- a/tests/access.service.test.ts +++ b/tests/access.service.test.ts @@ -414,6 +414,67 @@ describe('AccessService', () => { }); }); }); + + describe('checkAccess request schema validation', () => { + it('rejects null params with an actionable GuildPassConfigError', async () => { + const { get, service } = createService(checkAccessSuccess); + + await expect(service.checkAccess(null as any)).rejects.toMatchObject({ + code: GuildPassErrorCode.INVALID_INPUT, + }); + expect(get).not.toHaveBeenCalled(); + }); + + it('rejects a non-object params value', async () => { + const { get, service } = createService(checkAccessSuccess); + + await expect(service.checkAccess('not-an-object' as any)).rejects.toBeInstanceOf( + GuildPassConfigError, + ); + expect(get).not.toHaveBeenCalled(); + }); + + it('names the malformed field and the endpoint in the error message', async () => { + const { service } = createService(checkAccessSuccess); + + await expect( + service.checkAccess({ walletAddress: mixedCaseAddress, guildId: 'guild_1' } as any), + ).rejects.toMatchObject({ + message: expect.stringContaining('GET /access/check'), + }); + }); + + it('still surfaces the specific INVALID_ADDRESS code for a malformed-but-present address (schema check is structural only)', async () => { + const { get, service } = createService(checkAccessSuccess); + + await expect( + service.checkAccess({ + walletAddress: 'invalid-address', + guildId: 'guild_1', + resourceId: 'resource_1', + }), + ).rejects.toMatchObject({ code: GuildPassErrorCode.INVALID_ADDRESS }); + expect(get).not.toHaveBeenCalled(); + }); + }); + + describe('checkRoleAccess request schema validation', () => { + it('rejects a non-object params value', async () => { + const { get, service } = createService(checkRoleAccessSuccess); + + await expect(service.checkRoleAccess(null as any)).rejects.toBeInstanceOf(GuildPassConfigError); + expect(get).not.toHaveBeenCalled(); + }); + + it('still surfaces the specific INVALID_INPUT code for an empty roleId (schema check is structural only)', async () => { + const { get, service } = createService(checkRoleAccessSuccess); + + await expect( + service.checkRoleAccess({ walletAddress: validAddress, guildId: 'guild_1', roleId: '' }), + ).rejects.toMatchObject({ code: GuildPassErrorCode.INVALID_INPUT }); + expect(get).not.toHaveBeenCalled(); + }); + }); }); describe('getAccessSummary', () => { diff --git a/tests/adaptive-contract-provider.test.ts b/tests/adaptive-contract-provider.test.ts index f496300..47dc79c 100644 --- a/tests/adaptive-contract-provider.test.ts +++ b/tests/adaptive-contract-provider.test.ts @@ -18,7 +18,6 @@ */ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; import { HttpClient } from '../src/http/httpClient'; -import { GuildPassErrorCode } from '../src/errors/errorCodes'; import { AdaptiveContractProvider } from '../src/contracts/providers/adaptiveContractProvider'; import { MULTICALL3_ADDRESS, diff --git a/tests/apiKeyProtection.test.ts b/tests/apiKeyProtection.test.ts index e60614a..9482242 100644 --- a/tests/apiKeyProtection.test.ts +++ b/tests/apiKeyProtection.test.ts @@ -4,7 +4,6 @@ import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; import { HttpClient } from '../src/http/httpClient'; // GuildPass SDK: Pull in package or module bindings. import { GuildPassClient } from '../src/client/GuildPassClient'; -import { GuildPassErrorCode } from '../src/errors/errorCodes'; // --------------------------------------------------------------------------- // Helpers @@ -344,7 +343,14 @@ describe('ContractClient RPC API key protection', () => { it('should still include X-API-Key in regular SDK API calls alongside contract calls', async () => { fetchMock - .mockResolvedValueOnce(mockJsonResponse({ hasAccess: true })) // checkAccess + .mockResolvedValueOnce(mockJsonResponse({ // checkAccess + hasAccess: true, + walletAddress: WALLET, + guildId: 'guild-1', + resourceId: 'res-1', + requiredRoles: [], + matchedRoles: [], + })) .mockResolvedValueOnce(mockJsonResponse({ // getGuildOwner result: `0x000000000000000000000000${'9'.repeat(40)}`, })); diff --git a/tests/cache-config-validation.test.ts b/tests/cache-config-validation.test.ts index c998b21..74a7d2b 100644 --- a/tests/cache-config-validation.test.ts +++ b/tests/cache-config-validation.test.ts @@ -1,4 +1,4 @@ -import { describe, it, expect, vi } from 'vitest'; +import { describe, it, expect } from 'vitest'; import { GuildPassClient } from '../src/client/GuildPassClient'; import { GuildPassError } from '../src/errors/GuildPassError'; import { GuildPassErrorCode } from '../src/errors/errorCodes'; diff --git a/tests/cache.test.ts b/tests/cache.test.ts index 056a67e..888ffae 100644 --- a/tests/cache.test.ts +++ b/tests/cache.test.ts @@ -56,10 +56,18 @@ describe('GuildPassClient – cache integration', () => { const mockGuild = { id: 'prime-guild', name: 'Prime Guild', - ownerAddress: '0xowner', + ownerAddress: '0xd8dA6BF26964aF9D7eEd9e03E53415D37aA96045', chainId: 1, }; - const mockAccess = { hasAccess: true, reason: null }; + const mockAccess = { + hasAccess: true, + walletAddress: '0xd8dA6BF26964aF9D7eEd9e03E53415D37aA96045', + guildId: 'g1', + resourceId: 'res1', + requiredRoles: [], + matchedRoles: [], + reason: null, + }; function buildMockAdapter(): CacheAdapter & { _store: Map } { const store = new Map(); @@ -409,7 +417,7 @@ it('invalidateGuildCache is a no-op when no adapter is configured', async () => }); describe('Resilience - cache failures', () => { - const mockGuild = { id: 'g1', name: 'G1', ownerAddress: '0x1', chainId: 1 }; + const mockGuild = { id: 'g1', name: 'G1', ownerAddress: '0xd8dA6BF26964aF9D7eEd9e03E53415D37aA96045', chainId: 1 }; it('falls back to network if cache.get() fails', async () => { const adapter = buildMockAdapter(); @@ -485,7 +493,15 @@ it('invalidateGuildCache is a no-op when no adapter is configured', async () => }); describe('GuildPassClient – cache-key collision resistance', () => { - const mockAccess = { hasAccess: true, reason: null }; + const mockAccess = { + hasAccess: true, + walletAddress: '0xd8dA6BF26964aF9D7eEd9e03E53415D37aA96045', + guildId: 'g1', + resourceId: 'res1', + requiredRoles: [], + matchedRoles: [], + reason: null, + }; const wallet = '0xd8dA6BF26964aF9D7eEd9e03E53415D37aA96045'; it('does not collide when a guildId/resourceId split shifts across the delimiter', async () => { @@ -661,10 +677,10 @@ describe('GuildPassClient – deleteByPrefix-absent adapter fallback', () => { * (line 167-168: exact-key deletion) and invalidateWalletCache * (line 190-191: full clear). */ - function createMinimalAdapter(): CacheAdapter & { _store: Map; clearSpy: ReturnType } { + function createMinimalAdapter(): CacheAdapter & { _store: Map; clearSpy: ReturnType>> } { const store = new Map(); const clearSpy = vi.fn(async () => { store.clear(); }); - const adapter: CacheAdapter & { _store: Map; clearSpy: ReturnType } = { + const adapter: CacheAdapter & { _store: Map; clearSpy: ReturnType>> } = { _store: store as unknown as Map, clearSpy, async get(key: string): Promise { @@ -689,7 +705,7 @@ describe('GuildPassClient – deleteByPrefix-absent adapter fallback', () => { it('invalidateGuildCache falls back to exact-key deletion when adapter lacks deleteByPrefix', async () => { const adapter = createMinimalAdapter(); const deleteSpy = vi.spyOn(adapter, 'delete'); - const mockGuild = { id: 'prime-guild', name: 'PG', ownerAddress: '0x1', chainId: 1 }; + const mockGuild = { id: 'prime-guild', name: 'PG', ownerAddress: '0xd8dA6BF26964aF9D7eEd9e03E53415D37aA96045', chainId: 1 }; // Populate exact-prefix keys that the SDK computes await adapter.set('guilds:getGuild:prime-guild', mockGuild); @@ -707,7 +723,7 @@ describe('GuildPassClient – deleteByPrefix-absent adapter fallback', () => { it('invalidateGuildCache fallback misses composite/nested keys (expected limitation)', async () => { const adapter = createMinimalAdapter(); - const mockGuild = { id: 'prime-guild', name: 'PG', ownerAddress: '0x1', chainId: 1 }; + const mockGuild = { id: 'prime-guild', name: 'PG', ownerAddress: '0xd8dA6BF26964aF9D7eEd9e03E53415D37aA96045', chainId: 1 }; // Composite keys with wallet/resource suffixes await adapter.set('access:checkAccess:prime-guild:premium-docs:0xabc', { hasAccess: true }); diff --git a/tests/cancellation.test.ts b/tests/cancellation.test.ts index e0e7511..4d556f6 100644 --- a/tests/cancellation.test.ts +++ b/tests/cancellation.test.ts @@ -13,7 +13,15 @@ const accessParams = { const okAccessResponse = () => ({ ok: true, status: 200, - json: () => Promise.resolve({ hasAccess: true, matchedRoles: [] }), + json: () => + Promise.resolve({ + hasAccess: true, + walletAddress: validAddress, + guildId: accessParams.guildId, + resourceId: accessParams.resourceId, + requiredRoles: [], + matchedRoles: [], + }), headers: new Headers(), }); diff --git a/tests/client.test.ts b/tests/client.test.ts index 39c528c..de8c524 100644 --- a/tests/client.test.ts +++ b/tests/client.test.ts @@ -140,10 +140,18 @@ describe('GuildPassClient', () => { }); it('should pass a custom fetch transport through to SDK requests', async () => { + const mockResult = { + hasAccess: true, + walletAddress: '0x742d35cc6634c0532925a3b844bc9e7595f0beef', + guildId: 'guild-1', + resourceId: 'resource-1', + requiredRoles: [], + matchedRoles: [], + }; const transport = vi.fn().mockResolvedValue({ ok: true, status: 200, - json: () => Promise.resolve({ hasAccess: true }), + json: () => Promise.resolve(mockResult), headers: new Headers(), }); const globalFetch = vi.fn(); @@ -160,7 +168,7 @@ describe('GuildPassClient', () => { resourceId: 'resource-1', }); - expect(result).toEqual({ hasAccess: true }); + expect(result).toEqual(mockResult); expect(transport).toHaveBeenCalled(); expect(transport).toHaveBeenCalledWith( expect.stringContaining('/access/check'), diff --git a/tests/compat/browser/browser.test.ts b/tests/compat/browser/browser.test.ts index 707a8e8..a92e066 100644 --- a/tests/compat/browser/browser.test.ts +++ b/tests/compat/browser/browser.test.ts @@ -9,7 +9,14 @@ describe('GuildPass SDK - Browser (JSDOM) Compatibility', () => { it('should support a custom fetch transport', async () => { const mockFetch = vi.fn().mockResolvedValue({ ok: true, - json: async () => ({ hasAccess: true }), + json: async () => ({ + hasAccess: true, + walletAddress: '0x1234567890123456789012345678901234567890', + guildId: 'test-guild', + resourceId: 'test-resource', + requiredRoles: [], + matchedRoles: [], + }), }); const originalFetch = globalThis.fetch; diff --git a/tests/compat/edge/edge.test.ts b/tests/compat/edge/edge.test.ts index 257d5d6..0f9f08b 100644 --- a/tests/compat/edge/edge.test.ts +++ b/tests/compat/edge/edge.test.ts @@ -103,9 +103,7 @@ describe('secp256k1 - Edge Runtime', () => { }); it('should perform ECDSA recovery', async () => { - const { ecRecover, hexToBytes, hashPersonalMessage, publicKeyToAddress } = await import( - '../../../src/crypto/secp256k1' - ); + const { ecRecover } = await import('../../../src/crypto/secp256k1'); // Test with a known signature structure (not verifying real sig, just the function) const msgHash = new Uint8Array(32); @@ -297,7 +295,7 @@ describe('GuildPassClient - Edge Runtime', () => { cacheTtl: 60_000, }); - expect(client.cache).toBeDefined(); // eslint-disable-line + expect((client as any).cache).toBeDefined(); expect(client.access).toBeDefined(); expect(client.membership).toBeDefined(); expect(client.roles).toBeDefined(); diff --git a/tests/config-validation-metadata.test.ts b/tests/config-validation-metadata.test.ts index 8a68122..13a46cd 100644 --- a/tests/config-validation-metadata.test.ts +++ b/tests/config-validation-metadata.test.ts @@ -2,8 +2,6 @@ import { describe, it, expect } from 'vitest'; // GuildPass SDK: Pull in package or module bindings. import { GuildPassError } from '../src/errors/GuildPassError'; -// GuildPass SDK: Import external module dependencies. -import { GuildPassErrorCode } from '../src/errors/errorCodes'; // GuildPass SDK: Pull in package or module bindings. import { validateConfig } from '../src/config/sdkConfig'; diff --git a/tests/contract-providers.test.ts b/tests/contract-providers.test.ts index 9abb6f2..7e255d0 100644 --- a/tests/contract-providers.test.ts +++ b/tests/contract-providers.test.ts @@ -83,7 +83,7 @@ describe('ContractProvider abstraction', () => { const client = new GuildPassClient({ apiUrl: BASE_URL, contractAddress: CONTRACT, - contractProvider: { ethCall: vi.fn(), batchEthCall }, + contractProvider: { ethCall: vi.fn(), batchEthCall: batchEthCall as any }, }); const results = await client.contracts.getMembershipTokenBalancesBatch({ @@ -293,7 +293,7 @@ describe('chunk concurrency', () => { new GuildPassClient({ apiUrl: BASE_URL, contractAddress: CONTRACT, - contractProvider: { ethCall: vi.fn(), batchEthCall }, + contractProvider: { ethCall: vi.fn(), batchEthCall: batchEthCall as any }, }); // Helper: produce N distinct wallet addresses for batch input. diff --git a/tests/contracts-consensus.test.ts b/tests/contracts-consensus.test.ts index fdd163d..36d50e7 100644 --- a/tests/contracts-consensus.test.ts +++ b/tests/contracts-consensus.test.ts @@ -50,6 +50,15 @@ const BALANCE_CALL_DATA = `${BALANCE_OF_SELECTOR}${encodeAddressArgument(WALLET) const mockFetch = (): ReturnType => fetch as unknown as ReturnType; +/** Minimal mocked `Response` shape shared by every route builder below. */ +type MockFetchResponse = { + ok: boolean; + status: number; + headers: Headers; + json: () => Promise; + text: () => Promise; +}; + const jsonRpcOk = (result: string) => ({ ok: true, status: 200, @@ -71,6 +80,7 @@ const transientHttp = (status: number) => ({ status, headers: new Headers(), json: () => Promise.resolve({ message: `HTTP ${status}` }), + text: () => Promise.resolve(JSON.stringify({ message: `HTTP ${status}` })), }); const contractRevert = () => ({ @@ -78,6 +88,7 @@ const contractRevert = () => ({ status: 200, headers: new Headers({ 'Content-Type': 'application/json' }), json: () => Promise.resolve({ jsonrpc: '2.0', id: 1, error: { code: -32000, message: 'execution reverted' } }), + text: () => Promise.resolve(JSON.stringify({ jsonrpc: '2.0', id: 1, error: { code: -32000, message: 'execution reverted' } })), }); /** @@ -85,9 +96,14 @@ const contractRevert = () => ({ * The keys are exact-match substrings the request URL must include. */ const urlMock = ( - routes: Record ReturnType>, - defaultRoute: () => ReturnType = () => - ({ ok: false, status: 500, headers: new Headers(), json: () => Promise.resolve({ message: 'no route' }) }) as any, + routes: Record MockFetchResponse>, + defaultRoute: () => MockFetchResponse = () => ({ + ok: false, + status: 500, + headers: new Headers(), + json: () => Promise.resolve({ message: 'no route' }), + text: () => Promise.resolve(JSON.stringify({ message: 'no route' })), + }), ) => { const fn = vi.fn(async (url: string) => { for (const [key, builder] of Object.entries(routes)) { @@ -749,6 +765,7 @@ describe('batch consensus (per-item quorum)', () => { status: 200, headers: new Headers({ 'Content-Type': 'application/json' }), json: () => Promise.resolve(items.map((i) => ({ jsonrpc: '2.0', ...i }))), + text: () => Promise.resolve(JSON.stringify(items.map((i) => ({ jsonrpc: '2.0', ...i })))), }); it('all providers agree on every batch item → all items succeed', async () => { diff --git a/tests/contracts-formatted-balance.test.ts b/tests/contracts-formatted-balance.test.ts index cc53bec..e5a3c24 100644 --- a/tests/contracts-formatted-balance.test.ts +++ b/tests/contracts-formatted-balance.test.ts @@ -59,14 +59,18 @@ describe('formatUnits', () => { }); describe('ContractClient token metadata', () => { - const client = new GuildPassClient({ - apiUrl: 'https://api.test.com', - rpcUrl: RPC_URL, - contractAddress: CONTRACT, - }); + let client: GuildPassClient; beforeEach(() => { + // Stub `fetch` before constructing the client: `FetchTransport`'s default + // parameter captures `globalThis.fetch` once, at construction time, so a + // client built before the stub is in place keeps using the real fetch. vi.stubGlobal('fetch', vi.fn()); + client = new GuildPassClient({ + apiUrl: 'https://api.test.com', + rpcUrl: RPC_URL, + contractAddress: CONTRACT, + }); }); afterEach(() => { vi.restoreAllMocks(); diff --git a/tests/contracts-multi-chain-balances.test.ts b/tests/contracts-multi-chain-balances.test.ts index 37d5ee2..1478e5d 100644 --- a/tests/contracts-multi-chain-balances.test.ts +++ b/tests/contracts-multi-chain-balances.test.ts @@ -1,6 +1,6 @@ import { beforeEach, describe, expect, it, vi } from 'vitest'; import { GuildPassClient } from '../src/client/GuildPassClient'; -import { ContractClient, encodeAddressArgument } from '../src/contracts/contractClient'; +import { ContractClient } from '../src/contracts/contractClient'; import { GuildPassErrorCode } from '../src/errors/errorCodes'; const WALLET = '0x1234567890123456789012345678901234567890'; @@ -11,9 +11,6 @@ const BASE_URL = 'https://api.test.com'; const BALANCE_ETH = '1000000000000000000'; // 1e18 const BALANCE_BASE = '500000000000000000'; // 0.5e18 -const encodedWallet = encodeAddressArgument(WALLET); -const BALANCE_OF_SELECTOR = '0x70a08231'; - function rpcSuccessResponse(balance: string) { return { ok: true, diff --git a/tests/contracts.test.ts b/tests/contracts.test.ts index 6beca3a..06bd197 100644 --- a/tests/contracts.test.ts +++ b/tests/contracts.test.ts @@ -7,7 +7,6 @@ import { GuildPassErrorCode } from '../src/errors/errorCodes'; import { GuildPassError } from '../src/errors/GuildPassError'; import { BALANCE_OF_SELECTOR, - ERC1155_BALANCE_OF_SELECTOR, GET_GUILD_OWNER_SELECTOR, encodeAddressArgument, encodeGuildId, diff --git a/tests/deduplication.test.ts b/tests/deduplication.test.ts index da13ebb..14e443c 100644 --- a/tests/deduplication.test.ts +++ b/tests/deduplication.test.ts @@ -321,9 +321,15 @@ describe('non-idempotent and uncached paths are never deduplicated', () => { it('does not coalesce concurrent isMember calls with includeMeta', async () => { const client = new GuildPassClient(BASE_CONFIG); + // `HttpClient.get` itself wraps the payload as `{ data, meta }` when + // `includeMeta: true` is passed — since this mock replaces `get` wholesale + // (bypassing that wrapping), it must supply the same shape `get` would. const httpSpy = vi .spyOn(client['http'] as any, 'get') - .mockResolvedValue({ isActive: true, joinedAt: '2026-01-01T00:00:00Z' }); + .mockResolvedValue({ + data: { walletAddress: ADDR, guildId: 'g1', isActive: true, roles: [], joinedAt: '2026-01-01T00:00:00Z' }, + meta: { status: 200, durationMs: 0 }, + }); const params = { walletAddress: ADDR, guildId: 'g1' }; await Promise.all([ @@ -334,3 +340,82 @@ describe('non-idempotent and uncached paths are never deduplicated', () => { expect(httpSpy).toHaveBeenCalledTimes(2); }); }); + +describe('a caller-supplied signal opts a call out of coalescing', () => { + it('does not coalesce a signalled call with a concurrent identical signal-less call', async () => { + const client = new GuildPassClient(BASE_CONFIG); + const httpSpy = vi.spyOn(client['http'] as any, 'get').mockResolvedValue(mockAccess); + const controller = new AbortController(); + + const [withSignal, withoutSignal] = await Promise.all([ + client.access.checkAccess( + { walletAddress: ADDR, guildId: 'g1', resourceId: 'r1' }, + { signal: controller.signal }, + ), + client.access.checkAccess({ walletAddress: ADDR, guildId: 'g1', resourceId: 'r1' }), + ]); + + expect(withSignal).toEqual(mockAccess); + expect(withoutSignal).toEqual(mockAccess); + // Each caller gets its own request precisely because one of them carries + // a signal — sharing an in-flight request would mean the signalled + // caller's abort could reject the other, unrelated caller too. + expect(httpSpy).toHaveBeenCalledTimes(2); + }); + + it('still coalesces two identical signal-less calls (unaffected by the signal check)', async () => { + const client = new GuildPassClient(BASE_CONFIG); + const httpSpy = vi.spyOn(client['http'] as any, 'get').mockResolvedValue(mockAccess); + + await Promise.all([ + client.access.checkAccess({ walletAddress: ADDR, guildId: 'g1', resourceId: 'r1' }), + client.access.checkAccess({ walletAddress: ADDR, guildId: 'g1', resourceId: 'r1' }), + ]); + + expect(httpSpy).toHaveBeenCalledTimes(1); + }); + + it('honours an explicit deduplicate: true override even when a signal is present', async () => { + const client = new GuildPassClient(BASE_CONFIG); + const httpSpy = vi.spyOn(client['http'] as any, 'get').mockResolvedValue(mockAccess); + const controller = new AbortController(); + + await Promise.all([ + client.access.checkAccess( + { walletAddress: ADDR, guildId: 'g1', resourceId: 'r1' }, + { signal: controller.signal, deduplicate: true }, + ), + client.access.checkAccess({ walletAddress: ADDR, guildId: 'g1', resourceId: 'r1' }), + ]); + + expect(httpSpy).toHaveBeenCalledTimes(1); + }); +}); + +describe('deduplicate is never forwarded to the transport layer', () => { + it('does not include a "deduplicate" key in the raw HTTP call options', async () => { + const client = new GuildPassClient(BASE_CONFIG); + const httpSpy = vi.spyOn(client['http'] as any, 'get').mockResolvedValue(mockAccess); + + await client.access.checkAccess( + { walletAddress: ADDR, guildId: 'g1', resourceId: 'r1' }, + { deduplicate: false, timeoutMs: 500 }, + ); + + expect(httpSpy).toHaveBeenCalledTimes(1); + const [, calledOptions] = httpSpy.mock.calls[0]; + expect(calledOptions).not.toHaveProperty('deduplicate'); + expect(calledOptions).toMatchObject({ timeoutMs: 500 }); + }); + + it('does not include "deduplicate" in checkAccessBatch item requests either', async () => { + const client = new GuildPassClient(BASE_CONFIG); + const httpSpy = vi.spyOn(client['http'] as any, 'get').mockResolvedValue(mockAccess); + + await client.access.checkAccessBatch([{ walletAddress: ADDR, guildId: 'g1', resourceId: 'r1' }]); + + expect(httpSpy).toHaveBeenCalledTimes(1); + const [, calledOptions] = httpSpy.mock.calls[0]; + expect(calledOptions).not.toHaveProperty('deduplicate'); + }); +}); diff --git a/tests/evaluateRule.test.ts b/tests/evaluateRule.test.ts index 557320c..d96c3fc 100644 --- a/tests/evaluateRule.test.ts +++ b/tests/evaluateRule.test.ts @@ -1,4 +1,4 @@ -import { describe, it, expect, vi, beforeEach } from 'vitest'; +import { describe, it, expect, vi } from 'vitest'; import { evaluateRule } from '../src/rules/evaluateRule'; import type { AccessRule, RuleEvaluationClient, RuleEvaluationContext } from '../src/rules/rule.types'; import { GuildPassError } from '../src/errors/GuildPassError'; diff --git a/tests/fetchTransport.test.ts b/tests/fetchTransport.test.ts new file mode 100644 index 0000000..0b394d6 --- /dev/null +++ b/tests/fetchTransport.test.ts @@ -0,0 +1,80 @@ +import { describe, it, expect, vi, afterEach } from 'vitest'; +import { FetchTransport } from '../src/network/fetchTransport'; + +function mockResponse(body: unknown, init: { status?: number; headers?: Record } = {}) { + return { + ok: (init.status ?? 200) < 300, + status: init.status ?? 200, + headers: new Headers(init.headers ?? { 'Content-Type': 'application/json' }), + json: () => Promise.resolve(body), + } as Response; +} + +describe('FetchTransport', () => { + afterEach(() => { + vi.unstubAllGlobals(); + }); + + it('uses an explicitly passed fetch function', async () => { + const explicitFetch = vi.fn().mockResolvedValue(mockResponse({ ok: true })); + const transport = new FetchTransport(explicitFetch); + + await transport.execute({ url: 'https://api.test.com/x', method: 'GET', headers: {} }); + + expect(explicitFetch).toHaveBeenCalledTimes(1); + }); + + it('resolves globalThis.fetch lazily at execute() time, not at construction time', async () => { + // Construct the transport BEFORE any fetch is stubbed — this is exactly + // the pattern GuildPassClient uses (HttpClient/FetchTransport are built + // once, inside the constructor). A transport that captured + // `globalThis.fetch` eagerly via a default parameter would permanently + // keep whatever fetch existed at construction time, so a fetch installed + // afterwards (a polyfill, or `vi.stubGlobal` in a test) would silently + // never be used. + const transport = new FetchTransport(); + + const stubbedFetch = vi.fn().mockResolvedValue(mockResponse({ ok: true })); + vi.stubGlobal('fetch', stubbedFetch); + + await transport.execute({ url: 'https://api.test.com/x', method: 'GET', headers: {} }); + + expect(stubbedFetch).toHaveBeenCalledTimes(1); + }); + + it('picks up a fetch replacement even between two execute() calls on the same instance', async () => { + const transport = new FetchTransport(); + + const firstFetch = vi.fn().mockResolvedValue(mockResponse({ call: 1 })); + vi.stubGlobal('fetch', firstFetch); + await transport.execute({ url: 'https://api.test.com/a', method: 'GET', headers: {} }); + + const secondFetch = vi.fn().mockResolvedValue(mockResponse({ call: 2 })); + vi.stubGlobal('fetch', secondFetch); + await transport.execute({ url: 'https://api.test.com/b', method: 'GET', headers: {} }); + + expect(firstFetch).toHaveBeenCalledTimes(1); + expect(secondFetch).toHaveBeenCalledTimes(1); + }); + + it('throws GuildPassConfigError when no fetch is available anywhere', async () => { + vi.stubGlobal('fetch', undefined); + const transport = new FetchTransport(); + + await expect( + transport.execute({ url: 'https://api.test.com/x', method: 'GET', headers: {} }), + ).rejects.toMatchObject({ code: 'INVALID_CONFIG' }); + }); + + it('extracts headers via forEach when available', async () => { + const fetchFn = vi + .fn() + .mockResolvedValue(mockResponse({}, { headers: { 'x-request-id': 'abc', 'content-type': 'application/json' } })); + const transport = new FetchTransport(fetchFn); + + const response = await transport.execute({ url: 'https://api.test.com/x', method: 'GET', headers: {} }); + + expect(response.getHeader('x-request-id')).toBe('abc'); + expect(response.getHeaders()).toMatchObject({ 'x-request-id': 'abc' }); + }); +}); diff --git a/tests/guilds.service.test.ts b/tests/guilds.service.test.ts index 8638588..2075cb5 100644 --- a/tests/guilds.service.test.ts +++ b/tests/guilds.service.test.ts @@ -1,4 +1,4 @@ -import { describe, expect, it, vi, beforeEach } from 'vitest'; +import { describe, expect, it, vi } from 'vitest'; import { GuildsService } from '../src/guilds/guilds.service'; import { GuildPassConfigError } from '../src/errors/errorTypes'; import type { HttpClient } from '../src/http/httpClient'; @@ -18,6 +18,20 @@ describe('GuildsService validation errors', () => { await expect(service.getGuild({ guildId: '' })).rejects.toBeInstanceOf(GuildPassConfigError); expect(get).not.toHaveBeenCalled(); }); + + it('rejects a non-object params value for getGuild via the request schema', async () => { + const { get, service } = createService(getGuildSuccess); + + await expect(service.getGuild(undefined as any)).rejects.toBeInstanceOf(GuildPassConfigError); + expect(get).not.toHaveBeenCalled(); + }); + + it('rejects a non-object params value for getGuildConfig via the request schema', async () => { + const { get, service } = createService(getGuildConfigSuccess); + + await expect(service.getGuildConfig(undefined as any)).rejects.toBeInstanceOf(GuildPassConfigError); + expect(get).not.toHaveBeenCalled(); + }); }); describe('GuildsService request options forwarding', () => { diff --git a/tests/membership.service.test.ts b/tests/membership.service.test.ts index 3fae53a..9aa734d 100644 --- a/tests/membership.service.test.ts +++ b/tests/membership.service.test.ts @@ -1,4 +1,4 @@ -import { describe, expect, it, vi, beforeEach } from 'vitest'; +import { describe, expect, it, vi } from 'vitest'; import { MembershipService } from '../src/membership/membership.service'; import { GuildPassConfigError } from '../src/errors/errorTypes'; import type { HttpClient } from '../src/http/httpClient'; @@ -21,6 +21,13 @@ describe('MembershipService validation errors', () => { ).rejects.toBeInstanceOf(GuildPassConfigError); expect(get).not.toHaveBeenCalled(); }); + + it('rejects a non-object params value via the request schema', async () => { + const { get, service } = createService(getMembershipSuccess); + + await expect(service.getMembership(null as any)).rejects.toBeInstanceOf(GuildPassConfigError); + expect(get).not.toHaveBeenCalled(); + }); }); describe('MembershipService request options forwarding', () => { diff --git a/tests/metadata.test.ts b/tests/metadata.test.ts index 4ff8bb7..0a2beac 100644 --- a/tests/metadata.test.ts +++ b/tests/metadata.test.ts @@ -10,7 +10,15 @@ function mockFetch(headers?: Record) { return vi.fn().mockResolvedValue({ ok: true, status: 200, - json: () => Promise.resolve({ ok: true }), + json: () => + Promise.resolve({ + hasAccess: true, + walletAddress: '0x1234567890123456789012345678901234567890', + guildId: 'guild-1', + resourceId: 'res-1', + requiredRoles: [], + matchedRoles: [], + }), headers: new Headers(headers ?? { 'Content-Type': 'application/json' }), }); } diff --git a/tests/multicall3.test.ts b/tests/multicall3.test.ts index 11e9907..e10e9fa 100644 --- a/tests/multicall3.test.ts +++ b/tests/multicall3.test.ts @@ -1,10 +1,6 @@ import { beforeEach, describe, expect, it, vi } from 'vitest'; import { GuildPassClient } from '../src/client/GuildPassClient'; -import { GuildPassErrorCode } from '../src/errors/errorCodes'; -import { Multicall3ContractProvider } from '../src/contracts/providers/multicall3Provider'; -import { HttpClient } from '../src/http/httpClient'; import { MULTICALL3_ADDRESS } from '../src/contracts/providers/adaptive.types'; -import { BALANCE_OF_SELECTOR, GET_GUILD_OWNER_SELECTOR, encodeAddressArgument, encodeGuildId } from '../src/contracts/contractClient'; const BASE_URL = 'https://api.test.com'; const RPC_URL = 'https://rpc.test.com'; diff --git a/tests/pagination.test.ts b/tests/pagination.test.ts index e424fa6..28e24ae 100644 --- a/tests/pagination.test.ts +++ b/tests/pagination.test.ts @@ -3,7 +3,7 @@ import { PaginatedResult, paginateAll } from '../src/utils/pagination'; describe('paginateAll', () => { it('should fetch all pages until hasMore is false', async () => { - const fetchPage = vi.fn() + const fetchPage = vi.fn<[cursor?: string], Promise>>() .mockResolvedValueOnce({ items: [1, 2], nextCursor: 'cursor-1', @@ -33,7 +33,7 @@ describe('paginateAll', () => { }); it('should handle empty pages', async () => { - const fetchPage = vi.fn().mockResolvedValueOnce({ + const fetchPage = vi.fn<[cursor?: string], Promise>>().mockResolvedValueOnce({ items: [], nextCursor: undefined, hasMore: false, @@ -50,7 +50,7 @@ describe('paginateAll', () => { }); it('should stop immediately if hasMore is false', async () => { - const fetchPage = vi.fn().mockResolvedValueOnce({ + const fetchPage = vi.fn<[cursor?: string], Promise>>().mockResolvedValueOnce({ items: [1], nextCursor: 'random', hasMore: false, diff --git a/tests/per-request-timeout.test.ts b/tests/per-request-timeout.test.ts index d22c4b9..9caaae7 100644 --- a/tests/per-request-timeout.test.ts +++ b/tests/per-request-timeout.test.ts @@ -23,7 +23,15 @@ describe('Per-request timeout behavior (#10)', () => { return Promise.resolve({ ok: true, status: 200, - json: () => Promise.resolve({ hasAccess: true, matchedRoles: [] }), + json: () => + Promise.resolve({ + hasAccess: true, + walletAddress: '0x1234567890123456789012345678901234567890', + guildId: 'guild_1', + resourceId: 'resource_1', + requiredRoles: [], + matchedRoles: [], + }), headers: new Headers(), }); }); @@ -154,7 +162,15 @@ describe('Per-request timeout behavior (#10)', () => { mockFetch.mockResolvedValue({ ok: true, status: 200, - json: () => Promise.resolve({ hasAccess: true, matchedRoles: [] }), + json: () => + Promise.resolve({ + hasAccess: true, + walletAddress: '0x1234567890123456789012345678901234567890', + guildId: 'guild_1', + resourceId: 'resource_1', + requiredRoles: [], + matchedRoles: [], + }), headers: new Headers(), }); @@ -165,6 +181,13 @@ describe('Per-request timeout behavior (#10)', () => { resourceId: 'res_1', }); - expect(result).toEqual({ hasAccess: true, matchedRoles: [] }); + expect(result).toEqual({ + hasAccess: true, + walletAddress: '0x1234567890123456789012345678901234567890', + guildId: 'guild_1', + resourceId: 'resource_1', + requiredRoles: [], + matchedRoles: [], + }); }); }); diff --git a/tests/requestGuards.test.ts b/tests/requestGuards.test.ts new file mode 100644 index 0000000..ee0b083 --- /dev/null +++ b/tests/requestGuards.test.ts @@ -0,0 +1,161 @@ +import { describe, it, expect } from 'vitest'; +import { + isAccessCheckParams, + isRoleAccessCheckParams, + isMembershipParams, + isGetRolesParams, + isGetUserRolesParams, + isGetGuildParams, +} from '../src/validation/requestGuards'; + +const VALID_ADDRESS = '0xf39Fd6e51aad88F6F4ce6aB8827279cffFb92266'; + +describe('isAccessCheckParams', () => { + const valid = { + walletAddress: VALID_ADDRESS, + guildId: 'guild_123', + resourceId: 'resource_abc', + }; + + it('returns true for a valid AccessCheckParams shape', () => { + expect(isAccessCheckParams(valid)).toBe(true); + }); + + it('returns false for null', () => { + expect(isAccessCheckParams(null)).toBe(false); + }); + + it('returns false for an array', () => { + expect(isAccessCheckParams([valid])).toBe(false); + }); + + it('returns false when walletAddress is missing', () => { + // eslint-disable-next-line @typescript-eslint/no-unused-vars + const { walletAddress, ...rest } = valid; + expect(isAccessCheckParams(rest)).toBe(false); + }); + + it('returns false when walletAddress is an empty string', () => { + expect(isAccessCheckParams({ ...valid, walletAddress: '' })).toBe(false); + }); + + it('returns false when guildId is not a string', () => { + expect(isAccessCheckParams({ ...valid, guildId: 123 })).toBe(false); + }); + + it('returns false when resourceId is an empty string', () => { + expect(isAccessCheckParams({ ...valid, resourceId: '' })).toBe(false); + }); + + it('is structural only: does not reject a malformed (non-checksummed) address', () => { + // Format/checksum enforcement stays the job of `validateAddress` in + // `src/utils/validation.ts`, which runs immediately after this guard. + expect(isAccessCheckParams({ ...valid, walletAddress: 'not-an-address' })).toBe(true); + }); + + it('passes through unknown extra fields', () => { + expect(isAccessCheckParams({ ...valid, extra: 'field' } as any)).toBe(true); + }); +}); + +describe('isRoleAccessCheckParams', () => { + const valid = { + walletAddress: VALID_ADDRESS, + guildId: 'guild_123', + roleId: 'role_1', + }; + + it('returns true for a valid RoleAccessCheckParams shape', () => { + expect(isRoleAccessCheckParams(valid)).toBe(true); + }); + + it('returns false for null', () => { + expect(isRoleAccessCheckParams(null)).toBe(false); + }); + + it('returns false when roleId is an empty string', () => { + expect(isRoleAccessCheckParams({ ...valid, roleId: '' })).toBe(false); + }); + + it('returns false when roleId is missing', () => { + // eslint-disable-next-line @typescript-eslint/no-unused-vars + const { roleId, ...rest } = valid; + expect(isRoleAccessCheckParams(rest)).toBe(false); + }); +}); + +describe('isMembershipParams', () => { + const valid = { walletAddress: VALID_ADDRESS, guildId: 'guild_123' }; + + it('returns true for a valid MembershipParams shape', () => { + expect(isMembershipParams(valid)).toBe(true); + }); + + it('returns false for undefined', () => { + expect(isMembershipParams(undefined)).toBe(false); + }); + + it('returns false when guildId is an empty string', () => { + expect(isMembershipParams({ ...valid, guildId: '' })).toBe(false); + }); +}); + +describe('isGetRolesParams', () => { + const valid = { guildId: 'guild_123' }; + + it('returns true for a valid GetRolesParams shape with no pagination', () => { + expect(isGetRolesParams(valid)).toBe(true); + }); + + it('returns true with cursor and limit set', () => { + expect(isGetRolesParams({ ...valid, cursor: 'abc', limit: 10 })).toBe(true); + }); + + it('returns false when guildId is missing', () => { + expect(isGetRolesParams({})).toBe(false); + }); + + it('returns false when limit is not a number', () => { + expect(isGetRolesParams({ ...valid, limit: '10' })).toBe(false); + }); + + it('returns false when cursor is not a string', () => { + expect(isGetRolesParams({ ...valid, cursor: 123 })).toBe(false); + }); + + it('accepts an empty-string cursor (pagination-token opacity is not this guard\'s concern)', () => { + expect(isGetRolesParams({ ...valid, cursor: '' })).toBe(true); + }); +}); + +describe('isGetUserRolesParams', () => { + const valid = { walletAddress: VALID_ADDRESS, guildId: 'guild_123' }; + + it('returns true for a valid GetUserRolesParams shape', () => { + expect(isGetUserRolesParams(valid)).toBe(true); + }); + + it('returns true with cursor and limit set', () => { + expect(isGetUserRolesParams({ ...valid, cursor: 'abc', limit: 5 })).toBe(true); + }); + + it('returns false when walletAddress is missing', () => { + // eslint-disable-next-line @typescript-eslint/no-unused-vars + const { walletAddress, ...rest } = valid; + expect(isGetUserRolesParams(rest)).toBe(false); + }); +}); + +describe('isGetGuildParams', () => { + it('returns true for a valid GetGuildParams shape', () => { + expect(isGetGuildParams({ guildId: 'guild_123' })).toBe(true); + }); + + it('returns false when guildId is an empty string', () => { + expect(isGetGuildParams({ guildId: '' })).toBe(false); + }); + + it('returns false for an array', () => { + expect(isGetGuildParams(['guild_123'])).toBe(false); + }); +}); diff --git a/tests/responseGuards.test.ts b/tests/responseGuards.test.ts index 5690bb8..8616b03 100644 --- a/tests/responseGuards.test.ts +++ b/tests/responseGuards.test.ts @@ -56,6 +56,14 @@ describe('isAccessCheckResult', () => { expect(isAccessCheckResult({ ...valid, reason: 'Some reason' })).toBe(true); expect(isAccessCheckResult({ ...valid, reason: undefined })).toBe(true); }); + + it('returns true when reason is null (a JSON API sends null for an absent field, not undefined)', () => { + expect(isAccessCheckResult({ ...valid, reason: null })).toBe(true); + }); + + it('returns true when requiredRoles/matchedRoles are empty arrays (public resource / full denial are valid, common results)', () => { + expect(isAccessCheckResult({ ...valid, requiredRoles: [], matchedRoles: [] })).toBe(true); + }); }); describe('isMembership', () => { diff --git a/tests/responseMetadata.test.ts b/tests/responseMetadata.test.ts index 568471b..2238fab 100644 --- a/tests/responseMetadata.test.ts +++ b/tests/responseMetadata.test.ts @@ -2,7 +2,7 @@ import { describe, it, expect, vi, beforeEach } from 'vitest'; import { HttpClient } from '../src/http/httpClient'; import { GuildPassErrorCode } from '../src/errors/errorCodes'; -import type { ResponseMetadata } from '../src/http/http.types'; +import type { ResponseMetadata, FetchLike } from '../src/http/http.types'; function mockResponse( status: number, @@ -19,10 +19,10 @@ function mockResponse( describe('Response Metadata', () => { let client: HttpClient; - let mockFetch: ReturnType; + let mockFetch: ReturnType, ReturnType>>; beforeEach(() => { - mockFetch = vi.fn(); + mockFetch = vi.fn, ReturnType>(); client = new HttpClient('https://api.test.com', undefined, 10000, { fetch: mockFetch, }); diff --git a/tests/retryConfig.test.ts b/tests/retryConfig.test.ts index df8dc9e..e854636 100644 --- a/tests/retryConfig.test.ts +++ b/tests/retryConfig.test.ts @@ -109,7 +109,14 @@ describe('Per-request retry configuration', () => { status: 503, headers: { 'Content-Type': 'application/json' }, })) - .mockResolvedValueOnce(new Response(JSON.stringify({ hasAccess: true }), { + .mockResolvedValueOnce(new Response(JSON.stringify({ + hasAccess: true, + walletAddress: '0x1111111111111111111111111111111111111111', + guildId: 'guild-1', + resourceId: 'resource-1', + requiredRoles: [], + matchedRoles: [], + }), { status: 200, headers: { 'Content-Type': 'application/json' }, })); @@ -120,6 +127,7 @@ describe('Per-request retry configuration', () => { retry: { maxRetries: 3, baseDelayMs: 375, + jitter: false, }, }); diff --git a/tests/roles.service.test.ts b/tests/roles.service.test.ts index fe51521..bae993c 100644 --- a/tests/roles.service.test.ts +++ b/tests/roles.service.test.ts @@ -1,12 +1,11 @@ -import { describe, expect, it, vi, beforeEach } from 'vitest'; +import { describe, expect, it, vi } from 'vitest'; import { RolesService } from '../src/roles/roles.service'; import type { HttpClient } from '../src/http/httpClient'; import type { AccessService } from '../src/access/access.service'; -import { GuildPassErrorCode } from '../src/errors/errorCodes'; import { GuildPassConfigError } from '../src/errors/errorTypes'; -import * as getRolesSuccess from './fixtures/roles/get-roles-success.json'; -import * as getRolesPaginatedSuccess from './fixtures/roles/get-roles-paginated-success.json'; -import * as getUserRolesSuccess from './fixtures/roles/get-user-roles-success.json'; +import getRolesSuccess from './fixtures/roles/get-roles-success.json'; +import getRolesPaginatedSuccess from './fixtures/roles/get-roles-paginated-success.json'; +import getUserRolesSuccess from './fixtures/roles/get-user-roles-success.json'; const validAddress = '0x1234567890123456789012345678901234567890'; @@ -26,7 +25,7 @@ function createServiceWithAccess(accessReturnValue: boolean) { describe('RolesService request options forwarding', () => { it('forwards timeoutMs option to getRoles', async () => { - const { get, service } = createService(getRolesSuccess.default); + const { get, service } = createService(getRolesSuccess); await service.getRoles({ guildId: 'guild_1' }, { timeoutMs: 300 }); @@ -36,7 +35,7 @@ describe('RolesService request options forwarding', () => { }); it('forwards signal option to getRoles', async () => { - const { get, service } = createService(getRolesSuccess.default); + const { get, service } = createService(getRolesSuccess); const controller = new AbortController(); await service.getRoles({ guildId: 'guild_1' }, { signal: controller.signal }); @@ -47,7 +46,7 @@ describe('RolesService request options forwarding', () => { }); it('forwards retry option to getRoles', async () => { - const { get, service } = createService(getRolesSuccess.default); + const { get, service } = createService(getRolesSuccess); await service.getRoles({ guildId: 'guild_1' }, { retry: { maxRetries: 2 } }); @@ -57,7 +56,7 @@ describe('RolesService request options forwarding', () => { }); it('forwards timeoutMs option to getUserRoles', async () => { - const { get, service } = createService(getUserRolesSuccess.default); + const { get, service } = createService(getUserRolesSuccess); await service.getUserRoles({ walletAddress: validAddress, guildId: 'guild_1' }, { timeoutMs: 400 }); @@ -68,7 +67,7 @@ describe('RolesService request options forwarding', () => { }); it('forwards signal option to getUserRoles', async () => { - const { get, service } = createService(getUserRolesSuccess.default); + const { get, service } = createService(getUserRolesSuccess); const controller = new AbortController(); await service.getUserRoles({ walletAddress: validAddress, guildId: 'guild_1' }, { signal: controller.signal }); @@ -80,7 +79,7 @@ describe('RolesService request options forwarding', () => { }); it('forwards retry option to getUserRoles', async () => { - const { get, service } = createService(getUserRolesSuccess.default); + const { get, service } = createService(getUserRolesSuccess); await service.getUserRoles({ walletAddress: validAddress, guildId: 'guild_1' }, { retry: { maxRetries: 3 } }); @@ -91,7 +90,7 @@ describe('RolesService request options forwarding', () => { }); it('forwards all options together to getUserRoles', async () => { - const { get, service } = createService(getUserRolesSuccess.default); + const { get, service } = createService(getUserRolesSuccess); const controller = new AbortController(); await service.getUserRoles( @@ -112,7 +111,7 @@ describe('RolesService request options forwarding', () => { describe('RolesService pagination', () => { it('forwards cursor and limit to getRoles as params', async () => { - const { get, service } = createService(getRolesPaginatedSuccess.default); + const { get, service } = createService(getRolesPaginatedSuccess); await service.getRoles({ guildId: 'guild_1', cursor: 'abc', limit: 10 }); @@ -122,15 +121,15 @@ describe('RolesService pagination', () => { }); it('returns PaginatedResult for getRoles when pagination requested but API returns array', async () => { - const { get, service } = createService(getRolesSuccess.default); + const { service } = createService(getRolesSuccess); const result = await service.getRoles({ guildId: 'guild_1', cursor: 'abc' }); - expect(result).toEqual({ items: getRolesSuccess.default, hasMore: false }); + expect(result).toEqual({ items: getRolesSuccess, hasMore: false }); }); it('forwards cursor and limit to getUserRoles as params', async () => { - const { get, service } = createService(getRolesPaginatedSuccess.default); + const { get, service } = createService(getRolesPaginatedSuccess); await service.getUserRoles({ walletAddress: validAddress, guildId: 'guild_1', cursor: 'abc', limit: 5 }); @@ -141,6 +140,31 @@ describe('RolesService pagination', () => { }); }); +describe('RolesService request schema validation', () => { + it('rejects a non-object params value for getRoles', async () => { + const { get, service } = createService(getRolesSuccess); + + await expect(service.getRoles(null as any)).rejects.toBeInstanceOf(GuildPassConfigError); + expect(get).not.toHaveBeenCalled(); + }); + + it('rejects a non-object params value for getUserRoles', async () => { + const { get, service } = createService(getUserRolesSuccess); + + await expect(service.getUserRoles(null as any)).rejects.toBeInstanceOf(GuildPassConfigError); + expect(get).not.toHaveBeenCalled(); + }); + + it('rejects a non-number limit for getRoles before any network call', async () => { + const { get, service } = createService(getRolesSuccess); + + await expect( + service.getRoles({ guildId: 'guild_1', limit: '10' } as any), + ).rejects.toBeInstanceOf(GuildPassConfigError); + expect(get).not.toHaveBeenCalled(); + }); +}); + describe('RolesService.hasRole', () => { it('returns true when the wallet holds the role', async () => { const { checkRoleAccess, service } = createServiceWithAccess(true); diff --git a/tests/schema.test.ts b/tests/schema.test.ts index b974e30..83e1bee 100644 --- a/tests/schema.test.ts +++ b/tests/schema.test.ts @@ -18,7 +18,6 @@ import { record, object, strictObject, - type Validator, } from '../src/validation/schema'; // --------------------------------------------------------------------------- @@ -164,8 +163,8 @@ describe('optional()', () => { expect(v('hello')).toBe(true); }); - it('rejects null', () => { - expect(v(null)).toBe(false); + it('accepts null (a JSON API can only omit a key or send null — never a literal undefined)', () => { + expect(v(null)).toBe(true); }); it('rejects a number', () => { diff --git a/tests/security-config.test.ts b/tests/security-config.test.ts index 0afe41c..98326d2 100644 --- a/tests/security-config.test.ts +++ b/tests/security-config.test.ts @@ -72,7 +72,15 @@ describe('emitSecurityConfigWarnings', () => { }); describe('access cache TTL enforcement', () => { - const mockAccess = { hasAccess: true, reason: null }; + const mockAccess = { + hasAccess: true, + walletAddress: WALLET, + guildId: 'prime-guild', + resourceId: 'premium-docs', + requiredRoles: [], + matchedRoles: [], + reason: null, + }; beforeEach(() => { vi.useFakeTimers(); @@ -118,7 +126,7 @@ describe('access cache TTL enforcement', () => { const mockGuild = { id: 'prime-guild', name: 'Prime Guild', - ownerAddress: '0xowner', + ownerAddress: WALLET, chainId: 1, }; diff --git a/tests/serialization-compat.test.ts b/tests/serialization-compat.test.ts new file mode 100644 index 0000000..1d9ea38 --- /dev/null +++ b/tests/serialization-compat.test.ts @@ -0,0 +1,101 @@ +/** + * End-to-end coverage for the request/response schema pattern introduced + * for `AccessCheckParams` (request) and `AccessCheckResult` (response) — + * see docs/serialization-validation.md. These tests exist independently of + * `access.service.test.ts` (call-site wiring) and `requestGuards.test.ts` / + * `responseGuards.test.ts` (individual guard predicates) to pin down the + * cross-cutting contract: round-tripping, the unknown-field policy, and + * backward compatibility with payloads that predate this layer. + */ +import { describe, it, expect, vi } from 'vitest'; +import { AccessService } from '../src/access/access.service'; +import { isAccessCheckResult } from '../src/validation/responseGuards'; +import { isAccessCheckParams } from '../src/validation/requestGuards'; +import { assertValidResponse } from '../src/validation/assertResponse'; +import type { AccessCheckResult } from '../src/access/access.types'; +import type { HttpClient } from '../src/http/httpClient'; +import checkAccessSuccess from './fixtures/access/check-access-success.json'; + +describe('AccessCheckResult round-trip', () => { + it('survives a JSON.stringify -> JSON.parse round trip and still validates', () => { + const model: AccessCheckResult = { + hasAccess: true, + walletAddress: '0x1234567890123456789012345678901234567890', + guildId: 'guild_1', + resourceId: 'resource_1', + requiredRoles: ['member'], + matchedRoles: ['member'], + reason: 'matched required role', + }; + + const roundTripped = JSON.parse(JSON.stringify(model)); + + expect(roundTripped).toEqual(model); + expect(isAccessCheckResult(roundTripped)).toBe(true); + }); +}); + +describe('AccessCheckResult unknown-field policy', () => { + it('passes through a field the server added that the SDK does not model yet', () => { + const withNewServerField = { + ...checkAccessSuccess, + // Simulates the API team shipping a new field before the SDK models it. + expiresAt: '2026-12-31T00:00:00Z', + }; + + // Must still validate: rejecting here would break every consumer on + // SDK upgrade day the API adds a field, before any SDK release ships + // support for it. See docs/serialization-validation.md ("Unknown + // fields") for the passthrough policy this asserts. + expect(isAccessCheckResult(withNewServerField)).toBe(true); + expect(() => + assertValidResponse(withNewServerField, isAccessCheckResult, 'AccessCheckResult'), + ).not.toThrow(); + + // The extra field is neither stripped nor rejected — it's returned as-is. + const validated = assertValidResponse(withNewServerField, isAccessCheckResult, 'AccessCheckResult'); + expect((validated as any).expiresAt).toBe('2026-12-31T00:00:00Z'); + }); +}); + +describe('AccessCheckResult error shape', () => { + it('names the offending field, what was expected, and what was received', () => { + try { + assertValidResponse( + { ...checkAccessSuccess, hasAccess: 'yes' }, + isAccessCheckResult, + 'AccessCheckResult', + { endpoint: 'GET /access/check' }, + ); + throw new Error('expected assertValidResponse to throw'); + } catch (err: any) { + expect(err.message).toContain('AccessCheckResult'); + expect(err.message).toContain('GET /access/check'); + expect(err.message).toContain('hasAccess'); + expect(err.details.mismatch).toContain('expected a boolean'); + } + }); +}); + +describe('Legacy payload compatibility', () => { + it('a pre-existing fixture payload (captured before this layer existed) still passes both request and response validation', async () => { + // `check-access-success.json` predates the request/response schema + // work — it is the exact fixture `access.service.test.ts` already + // relies on for the happy path. This test pins that it is untouched + // by the new layer. + expect(isAccessCheckResult(checkAccessSuccess)).toBe(true); + + const validParams = { + walletAddress: '0x1234567890123456789012345678901234567890', + guildId: 'guild_1', + resourceId: 'resource_1', + }; + expect(isAccessCheckParams(validParams)).toBe(true); + + const get = vi.fn().mockResolvedValue(checkAccessSuccess); + const service = new AccessService({ get } as unknown as HttpClient); + + const result = await service.checkAccess(validParams); + expect(result).toEqual(checkAccessSuccess); + }); +}); diff --git a/tests/serviceUrlEncoding.test.ts b/tests/serviceUrlEncoding.test.ts index 42b5b9e..21c744b 100644 --- a/tests/serviceUrlEncoding.test.ts +++ b/tests/serviceUrlEncoding.test.ts @@ -27,7 +27,14 @@ function calledUrl(fetchTransport: ReturnType, callIndex = 0): str describe('service URL encoding', () => { it('encodes access query parameters with special characters exactly once', async () => { - const { client, fetchTransport } = createClient({ hasAccess: false }); + const { client, fetchTransport } = createClient({ + hasAccess: false, + walletAddress: normalisedAddress, + guildId: 'guild/alpha beta', + resourceId: 'resource/one?flag=yes&x=1', + requiredRoles: [], + matchedRoles: [], + }); await client.access.checkAccess({ walletAddress: mixedCaseAddress, @@ -44,7 +51,12 @@ describe('service URL encoding', () => { }); it('encodes membership query parameters without double-encoding values', async () => { - const { client, fetchTransport } = createClient({ isActive: true, roles: [] }); + const { client, fetchTransport } = createClient({ + walletAddress: normalisedAddress, + guildId: 'guild/member space', + isActive: true, + roles: [], + }); await client.membership.getMembership({ walletAddress: mixedCaseAddress, @@ -76,7 +88,12 @@ describe('service URL encoding', () => { }); it('encodes guild service path segments for metadata and config requests', async () => { - const { client, fetchTransport } = createClient({ id: 'guild/1' }); + const { client, fetchTransport } = createClient({ + id: 'guild/1', + name: 'Guild One', + ownerAddress: normalisedAddress, + chainId: 1, + }); await client.guilds.getGuild({ guildId: 'guild/main config' }); await client.guilds.getGuildConfig({ guildId: 'guild/main config' }); diff --git a/tests/services.test.ts b/tests/services.test.ts index 0411d15..ca55918 100644 --- a/tests/services.test.ts +++ b/tests/services.test.ts @@ -28,7 +28,14 @@ describe('Service Modules', () => { describe('AccessService', () => { it('should call checkAccess endpoint', async () => { - const mockResult = { hasAccess: true, matchedRoles: ['admin'] }; + const mockResult = { + hasAccess: true, + walletAddress: '0x1234567890123456789012345678901234567890', + guildId: 'guild_1', + resourceId: 'res_1', + requiredRoles: ['admin'], + matchedRoles: ['admin'], + }; mockFetch.mockResolvedValue({ ok: true, status: 200, @@ -52,7 +59,14 @@ describe('Service Modules', () => { }); it('should normalise wallet address in query parameters', async () => { - const mockResult = { hasAccess: true, matchedRoles: ['admin'] }; + const mockResult = { + hasAccess: true, + walletAddress: '0x1234567890123456789012345678901234567890', + guildId: 'guild_1', + resourceId: 'res_1', + requiredRoles: ['admin'], + matchedRoles: ['admin'], + }; (fetch as any).mockResolvedValue({ ok: true, status: 200, @@ -102,7 +116,14 @@ describe('Service Modules', () => { describe('checkAccessBatch', () => { it('should process multiple access checks and preserve order', async () => { - const mockResult = { hasAccess: true, matchedRoles: ['admin'] }; + const mockResult = { + hasAccess: true, + walletAddress: '0x1234567890123456789012345678901234567890', + guildId: 'guild_1', + resourceId: 'res_1', + requiredRoles: ['admin'], + matchedRoles: ['admin'], + }; (fetch as any).mockResolvedValue({ ok: true, status: 200, @@ -124,7 +145,14 @@ describe('Service Modules', () => { }); it('should handle partial failures without discarding successes', async () => { - const mockResult = { hasAccess: true, matchedRoles: ['admin'] }; + const mockResult = { + hasAccess: true, + walletAddress: '0x1234567890123456789012345678901234567890', + guildId: 'guild_1', + resourceId: 'res_1', + requiredRoles: ['admin'], + matchedRoles: ['admin'], + }; let callCount = 0; (fetch as any).mockImplementation(() => { callCount++; @@ -158,7 +186,14 @@ describe('Service Modules', () => { }); it('should fail fast if configured', async () => { - const mockResult = { hasAccess: true, matchedRoles: ['admin'] }; + const mockResult = { + hasAccess: true, + walletAddress: '0x1234567890123456789012345678901234567890', + guildId: 'guild_1', + resourceId: 'res_1', + requiredRoles: ['admin'], + matchedRoles: ['admin'], + }; let callCount = 0; (fetch as any).mockImplementation(() => { callCount++; @@ -188,7 +223,14 @@ describe('Service Modules', () => { }); it('should catch validation errors per item', async () => { - const mockResult = { hasAccess: true, matchedRoles: ['admin'] }; + const mockResult = { + hasAccess: true, + walletAddress: '0x1234567890123456789012345678901234567890', + guildId: 'guild_1', + resourceId: 'res_1', + requiredRoles: ['admin'], + matchedRoles: ['admin'], + }; (fetch as any).mockResolvedValue({ ok: true, status: 200, @@ -211,7 +253,12 @@ describe('Service Modules', () => { describe('MembershipService', () => { it('should call membership endpoint', async () => { - const mockMembership = { isActive: true, roles: ['member'] }; + const mockMembership = { + walletAddress: '0x1234567890123456789012345678901234567890', + guildId: 'guild_1', + isActive: true, + roles: ['member'], + }; mockFetch.mockResolvedValue({ ok: true, status: 200, @@ -491,7 +538,12 @@ describe('Service Modules', () => { describe('GuildsService', () => { it('should fetch guild info', async () => { - const mockGuild = { id: 'guild_1', name: 'Test Guild' }; + const mockGuild = { + id: 'guild_1', + name: 'Test Guild', + ownerAddress: '0x1234567890123456789012345678901234567890', + chainId: 1, + }; mockFetch.mockResolvedValue({ ok: true, status: 200, @@ -504,7 +556,12 @@ describe('Service Modules', () => { }); it('should URL-encode guild IDs in guild endpoint paths', async () => { - const mockGuild = { id: 'guild/1', name: 'Encoded Guild' }; + const mockGuild = { + id: 'guild/1', + name: 'Encoded Guild', + ownerAddress: '0x1234567890123456789012345678901234567890', + chainId: 1, + }; mockFetch.mockResolvedValue({ ok: true, status: 200, @@ -531,11 +588,31 @@ describe('Service Modules', () => { }); }); - it('is off by default, so malformed responses are passed through unchanged', async () => { + it('is on by default, so a malformed response is rejected without any config', async () => { const malformedResult = { hasAccess: true }; mockJsonResponse(malformedResult); - const result = await client.access.checkAccess({ + // `client` (outer beforeEach) sets no `validateResponses` at all — + // proves validation runs out of the box, not just when opted in. + await expect( + client.access.checkAccess({ + walletAddress: '0x1234567890123456789012345678901234567890', + guildId: 'guild_1', + resourceId: 'res_1', + }), + ).rejects.toMatchObject({ code: GuildPassErrorCode.INVALID_RESPONSE }); + }); + + it('validateResponses: false restores passing malformed responses through unchanged', async () => { + const nonValidatingClient = new GuildPassClient({ + apiUrl: 'https://api.test.com', + fetch: mockFetch, + validateResponses: false, + }); + const malformedResult = { hasAccess: true }; + mockJsonResponse(malformedResult); + + const result = await nonValidatingClient.access.checkAccess({ walletAddress: '0x1234567890123456789012345678901234567890', guildId: 'guild_1', resourceId: 'res_1', @@ -676,9 +753,14 @@ describe('Service Modules', () => { }); it('passes a malformed checkRoleAccess response through when validation is off', async () => { + const nonValidatingClient = new GuildPassClient({ + apiUrl: 'https://api.test.com', + fetch: mockFetch, + validateResponses: false, + }); mockJsonResponse({ hasRole: 'yes' }); - const result = await client.access.checkRoleAccess({ + const result = await nonValidatingClient.access.checkRoleAccess({ walletAddress: '0x1234567890123456789012345678901234567890', guildId: 'guild_1', roleId: 'role_1', @@ -999,10 +1081,18 @@ describe('strictAddressChecksum', () => { }); it('accepts non-checksummed addresses by default', async () => { + const mockResult = { + hasAccess: true, + walletAddress: nonChecksummedAddress, + guildId: 'guild_1', + resourceId: 'res_1', + requiredRoles: [], + matchedRoles: [], + }; const fetch = vi.fn().mockResolvedValue({ ok: true, status: 200, - json: () => Promise.resolve({ hasAccess: true }), + json: () => Promise.resolve(mockResult), headers: new Headers(), }); const client = new GuildPassClient({ apiUrl: 'https://api.test.com', fetch }); @@ -1011,6 +1101,6 @@ describe('strictAddressChecksum', () => { walletAddress: nonChecksummedAddress, guildId: 'guild_1', resourceId: 'res_1', - })).resolves.toEqual({ hasAccess: true }); + })).resolves.toEqual(mockResult); }); }); diff --git a/tests/signal-forwarding.test.ts b/tests/signal-forwarding.test.ts index e53a796..83beb2a 100644 --- a/tests/signal-forwarding.test.ts +++ b/tests/signal-forwarding.test.ts @@ -171,7 +171,14 @@ describe('Mid-batch cancellation — checkAccessBatch', () => { return new Promise((resolve, reject) => { const delay = isFirst ? 5 : 200; const timer = setTimeout(() => { - resolve(new Response(JSON.stringify({ allowed: true }), { + resolve(new Response(JSON.stringify({ + hasAccess: true, + walletAddress: validAddress, + guildId: 'guild_1', + resourceId: 'res_0', + requiredRoles: [], + matchedRoles: [], + }), { status: 200, headers: { 'Content-Type': 'application/json' }, })); diff --git a/tests/siwe-attack-scenarios.test.ts b/tests/siwe-attack-scenarios.test.ts index 8a1b2cf..6aed39e 100644 --- a/tests/siwe-attack-scenarios.test.ts +++ b/tests/siwe-attack-scenarios.test.ts @@ -18,8 +18,8 @@ import { generateSiweNonce, formatSiweMessage, parseSiweMessage, + type SiweMessage, } from '../src/siwe'; -import type { SiweMessage } from '../src/siwe'; import { GuildPassErrorCode } from '../src/errors/errorCodes'; const TEST_ADDRESS = '0xf39Fd6e51aad88F6F4ce6aB8827279cffFb92266'; diff --git a/tests/siwe-replay-protection.test.ts b/tests/siwe-replay-protection.test.ts index ec6d701..7cabd81 100644 --- a/tests/siwe-replay-protection.test.ts +++ b/tests/siwe-replay-protection.test.ts @@ -19,8 +19,8 @@ import { describe, it, expect } from 'vitest'; import { InMemoryNonceStore, verifySiweSignatureWithReplayProtection, + type NonceStore, } from '../src/siwe'; -import type { NonceStore } from '../src/siwe'; import { GuildPassErrorCode } from '../src/errors/errorCodes'; // --------------------------------------------------------------------------- diff --git a/tests/siwe.test.ts b/tests/siwe.test.ts index 3b71975..4a03caa 100644 --- a/tests/siwe.test.ts +++ b/tests/siwe.test.ts @@ -14,9 +14,9 @@ import { verifySiweSignature, generateSiweNonce, MAX_SIWE_MESSAGE_LENGTH, + type SiweMessage, } from '../src/siwe'; import { GuildPassErrorCode } from '../src/errors/errorCodes'; -import type { SiweMessage } from '../src/siwe'; // --------------------------------------------------------------------------- // Shared fixtures diff --git a/tests/testing/mockClient.test.ts b/tests/testing/mockClient.test.ts index 04b0040..2c7f172 100644 --- a/tests/testing/mockClient.test.ts +++ b/tests/testing/mockClient.test.ts @@ -1,8 +1,7 @@ import { describe, it, expect } from 'vitest'; -import { createMockGuildPassClient } from '../../src/testing/mockClient'; +import { createMockGuildPassClient, type Public } from '../../src/testing/mockClient'; import { DEFAULT_ACCESS_RESULT, DEFAULT_GUILD } from '../../src/testing/fixtures'; import type { GuildPassClient } from '../../src/client/GuildPassClient'; -import type { Public } from '../../src/testing/mockClient'; describe('MockGuildPassClient', () => { it('should implement the public interface of GuildPassClient at compile time', () => { diff --git a/tests/tokenBucket.test.ts b/tests/tokenBucket.test.ts index 31b83d8..599ab66 100644 --- a/tests/tokenBucket.test.ts +++ b/tests/tokenBucket.test.ts @@ -31,7 +31,6 @@ describe('TokenBucket', () => { await bucket.acquire(); // Next call should wait ~500ms - const start = Date.now(); const p = bucket.acquire(); vi.advanceTimersByTime(499); // Should still be waiting @@ -92,7 +91,12 @@ describe('TokenBucket', () => { // Retry-After: 2000ms means at most 0.5 req/s, so set to 0.4 req/s bucket.onRateLimited(2000); - await bucket.acquire(); // consume token + // onRateLimited(2000) also sets a hard 2000ms throttle that blocks every + // acquisition (even burst-capacity ones) until it elapses, so this first + // acquire() needs fake time advanced before it resolves. + const first = bucket.acquire(); // consume token + await vi.advanceTimersByTimeAsync(2000); + await first; // Next token should take ~2500ms (1/0.4 = 2500ms) const p = bucket.acquire(); diff --git a/tests/transport.test.ts b/tests/transport.test.ts index 406ec4b..cc981a6 100644 --- a/tests/transport.test.ts +++ b/tests/transport.test.ts @@ -12,10 +12,15 @@ describe('Transport Abstraction', () => { ok: true, getHeader: () => null, getHeaders: () => ({ 'content-type': 'application/json' }), - json: async () => ({ - isVerified: true, - result: true, - }), + json: async () => + ({ + hasAccess: true, + walletAddress: '0x1234567890123456789012345678901234567890', + guildId: 'guild-1', + resourceId: 'res-1', + requiredRoles: [], + matchedRoles: [], + } as T), }; } return { @@ -23,7 +28,7 @@ describe('Transport Abstraction', () => { ok: false, getHeader: () => null, getHeaders: () => ({ 'content-type': 'application/json' }), - json: async () => ({ error: 'Not Found' }), + json: async () => ({ error: 'Not Found' } as T), }; } } @@ -43,7 +48,7 @@ describe('Transport Abstraction', () => { }); expect(executeSpy).toHaveBeenCalled(); - expect(result.isVerified).toBe(true); - expect(result.result).toBe(true); + expect(result.hasAccess).toBe(true); + expect(result.walletAddress).toBe('0x1234567890123456789012345678901234567890'); }); }); diff --git a/tests/webSocketProvider.test.ts b/tests/webSocketProvider.test.ts index a646349..db8bd9d 100644 --- a/tests/webSocketProvider.test.ts +++ b/tests/webSocketProvider.test.ts @@ -492,7 +492,6 @@ describe('WebSocketContractProvider', () => { vi.useFakeTimers(); // Use a mock where we can verify no more WebSockets are created. - let wsCount = 0; const MockLimitedWS = class { readyState = WebSocket.CONNECTING; onopen: any = null; @@ -503,7 +502,6 @@ describe('WebSocketContractProvider', () => { url: string; constructor(url: string) { - wsCount++; this.url = url; } @@ -520,8 +518,6 @@ describe('WebSocketContractProvider', () => { maxDelayMs: 500, }); - const initialCount = wsCount; // 1 - // Force close 3 times (initial + 2 reconnects = 3 total, then stop). for (let i = 0; i < 3; i++) { // We need to simulate close. The issue is we don't have reference to instances. diff --git a/tests/whitelist/rotation.test.ts b/tests/whitelist/rotation.test.ts index cae35c7..b2bdf69 100644 --- a/tests/whitelist/rotation.test.ts +++ b/tests/whitelist/rotation.test.ts @@ -1,6 +1,5 @@ import { describe, it, expect, beforeEach } from 'vitest'; import { MerkleTree } from '../../src/utils/merkleTree'; -import { validateWhitelistRequirement } from '../../src/validators/whitelistValidator'; // Mock data const testAddresses = [ @@ -10,8 +9,6 @@ const testAddresses = [ '0x4567890123456789012345678901234567890123', ]; -const mockGuildId = 'guild_test_123'; - describe('Merkle Whitelist Rotation', () => { let tree: MerkleTree; let root: string; @@ -64,10 +61,8 @@ describe('Merkle Whitelist Rotation', () => { it('should handle addresses not in the tree', () => { const nonWhitelistedAddress = '0x9999999999999999999999999999999999999999'; - const proofForNonMember = tree.getProof(nonWhitelistedAddress); - // Note: In a real implementation, getProof would throw for non-members - - // Verify that non-members don't have valid proofs + + // getProof throws for non-members rather than returning an empty/invalid proof. expect(() => tree.getProof(nonWhitelistedAddress)).toThrow(); });