From 395f839d3b147b6f6b5613915d0ca838c526f783 Mon Sep 17 00:00:00 2001 From: samuelelijah585 Date: Wed, 29 Jul 2026 00:40:18 +0100 Subject: [PATCH] fix(sdk): add configurable timeout and retry with exponential backoff SDK fetch calls now support: - timeoutMs (default 10s) via AbortController to prevent hangs - maxRetries (default 2) on 5xx and network errors with backoff Closes blockchain-maxis/signet#61 docs: document SIGNET_SESSIONS_VALID_AFTER in SECURITY.md Adds operator guidance for bulk session revocation with a copy-pasteable command. Closes blockchain-maxis/signet#68 --- SECURITY.md | 16 +++++++- packages/sdk/src/client.test.ts | 60 +++++++++++++++++++++++++++- packages/sdk/src/client.ts | 70 +++++++++++++++++++++++++++++++-- 3 files changed, 140 insertions(+), 6 deletions(-) diff --git a/SECURITY.md b/SECURITY.md index 05d029c..75461a0 100644 --- a/SECURITY.md +++ b/SECURITY.md @@ -20,7 +20,21 @@ mitigation for confirmed high-severity issues within 30 days. - **`SIGNET_AUTH_SECRET`** must be set (≥16 random chars) in production — the app refuses the dev fallback when `NODE_ENV=production`. Rotate it (and - bump `SIGNET_SESSIONS_VALID_AFTER`) to revoke all sessions. + bump `SIGNET_SESSIONS_VALID_AFTER`, see below) to revoke all sessions. +- **`SIGNET_SESSIONS_VALID_AFTER`** invalidates any session whose `iat` + (issued-at) claim is older than this value. Set it to an + [ISO 8601](https://en.wikipedia.org/wiki/ISO_8601) timestamp to force all + existing sessions to be re-authenticated. This is the mechanism for + bulk-revoking sessions after a credential rotation or security incident. + + ```bash + # Revoke all sessions created before now + SIGNET_SESSIONS_VALID_AFTER=$(date -u +"%Y-%m-%dT%H:%M:%SZ") + ``` + + A good practice is to set this value to **the moment you rotate + `SIGNET_AUTH_SECRET`**, so that old-credential-issued sessions are rejected + immediately. The env var applies server-side and is read on each request. - The default rate limiter is per-instance; back it with a shared store (`setRateLimitStore`) for multi-instance deployments. - Security headers (CSP, HSTS, …) are set in `apps/web/next.config.js`. The CSP diff --git a/packages/sdk/src/client.test.ts b/packages/sdk/src/client.test.ts index 799373e..fad6da1 100644 --- a/packages/sdk/src/client.test.ts +++ b/packages/sdk/src/client.test.ts @@ -6,6 +6,7 @@ function mockFetch(payload: unknown, ok = true): typeof fetch { return (async (url: string | URL | Request) => { return { ok, + status: ok ? 200 : 500, url: String(url), json: async () => payload, } as Response; @@ -23,7 +24,7 @@ test('getProfile encodes the handle into the input query', async () => { let seen = ''; const fetchSpy = (async (url: string) => { seen = String(url); - return { ok: true, json: async () => ({ result: { data: null } }) } as Response; + return { ok: true, status: 200, json: async () => ({ result: { data: null } }) } as Response; }) as unknown as typeof fetch; const client = new SignetClient({ baseUrl: 'https://signet.dev/', fetch: fetchSpy }); await client.getProfile('aquawolf'); @@ -40,3 +41,60 @@ test('listHandles defaults to an empty array', async () => { const client = new SignetClient({ baseUrl: 'https://signet.dev', fetch: mockFetch({ result: { data: null } }) }); assert.deepEqual(await client.listHandles(), []); }); + +test('retries on 5xx then succeeds on the next attempt', async () => { + let callCount = 0; + const flakyFetch = (async () => { + callCount++; + if (callCount < 3) { + return { ok: false, status: 502, json: async () => ({}) } as Response; + } + return { ok: true, status: 200, json: async () => ({ result: { data: { handle: 'persistent' } } }) } as Response; + }) as unknown as typeof fetch; + + const client = new SignetClient({ baseUrl: 'https://signet.dev', fetch: flakyFetch, maxRetries: 3, timeoutMs: 5000 }); + const res = await client.getProfile('persistent'); + assert.deepEqual(res, { handle: 'persistent' }); + assert.equal(callCount, 3); +}); + +test('returns null when retries are exhausted on 5xx', async () => { + const alwaysFail = (async () => { + return { ok: false, status: 503, json: async () => ({}) } as Response; + }) as unknown as typeof fetch; + + const client = new SignetClient({ baseUrl: 'https://signet.dev', fetch: alwaysFail, maxRetries: 1, timeoutMs: 5000 }); + const res = await client.getProfile('ghost'); + assert.equal(res, null); +}); + +test('handles timeout via AbortController (aborted fetch returns null)', async () => { + // A fetch that never resolves (signals aborted, returns null) + const hangingFetch = (async (_url: string | URL | Request, init?: RequestInit) => { + // Wait for the signal to abort, then reject + await new Promise((_, reject) => { + if (init?.signal) { + (init.signal as AbortSignal).addEventListener('abort', () => { + reject(new DOMException('The operation was aborted', 'AbortError')); + }); + } + }); + // unreachable + return null as unknown as Response; + }) as unknown as typeof fetch; + + const client = new SignetClient({ baseUrl: 'https://signet.dev', fetch: hangingFetch, timeoutMs: 50, maxRetries: 0 }); + const res = await client.getProfile('timeout'); + assert.equal(res, null); +}); + +test('custom timeoutMs and maxRetries are applied', () => { + const client = new SignetClient({ + baseUrl: 'https://signet.dev', + timeoutMs: 5_000, + maxRetries: 3, + }); + // Access private fields via bracket notation to verify + assert.equal((client as unknown as Record).timeoutMs, 5_000); + assert.equal((client as unknown as Record).maxRetries, 3); +}); diff --git a/packages/sdk/src/client.ts b/packages/sdk/src/client.ts index 31fc8c1..2d88a09 100644 --- a/packages/sdk/src/client.ts +++ b/packages/sdk/src/client.ts @@ -5,6 +5,18 @@ export interface SignetClientOptions { baseUrl?: string; /** Optional fetch implementation (for tests / non-browser runtimes). */ fetch?: typeof fetch; + /** + * Request timeout in milliseconds. When set, the client will abort a + * request if the server does not respond within this window. + * @default 10_000 (10 seconds) + */ + timeoutMs?: number; + /** + * Maximum number of retry attempts for responses with status 5xx. + * Retries use an exponential backoff: 200ms, 400ms, 800ms, … + * @default 2 + */ + maxRetries?: number; } /** @@ -17,10 +29,14 @@ export interface SignetClientOptions { export class SignetClient { private readonly baseUrl: string; private readonly fetchImpl: typeof fetch; + private readonly timeoutMs: number; + private readonly maxRetries: number; constructor(options: SignetClientOptions = {}) { this.baseUrl = (options.baseUrl ?? 'https://signet.dev').replace(/\/$/, ''); this.fetchImpl = options.fetch ?? globalThis.fetch; + this.timeoutMs = options.timeoutMs ?? 10_000; + this.maxRetries = options.maxRetries ?? 2; if (!this.fetchImpl) { throw new Error('[signet] no fetch implementation available; pass options.fetch'); } @@ -30,10 +46,56 @@ export class SignetClient { const url = `${this.baseUrl}/api/trpc/${procedure}?input=${encodeURIComponent( JSON.stringify(input), )}`; - const res = await this.fetchImpl(url, { headers: { accept: 'application/json' } }); - if (!res.ok) return null; - const body = (await res.json()) as { result?: { data?: T } }; - return body.result?.data ?? null; + + for (let attempt = 0; attempt <= this.maxRetries; attempt++) { + const controller = new AbortController(); + const timer = setTimeout(() => controller.abort(), this.timeoutMs); + + try { + const res = await this.fetchImpl(url, { + headers: { accept: 'application/json' }, + signal: controller.signal, + }); + + if (!res.ok) { + // Retry on server errors (5xx), up to maxRetries + if (res.status >= 500 && res.status < 600 && attempt < this.maxRetries) { + clearTimeout(timer); + await this.backoff(attempt); + continue; + } + return null; + } + + const body = (await res.json()) as { result?: { data?: T } }; + return body.result?.data ?? null; + } catch (err: unknown) { + // AbortError (timeout) or network error – retry if attempts remain + if (attempt < this.maxRetries && this.isRetryableError(err)) { + clearTimeout(timer); + await this.backoff(attempt); + continue; + } + return null; + } finally { + clearTimeout(timer); + } + } + + return null; + } + + /** Exponential backoff: 200ms, 400ms, 800ms, … */ + private async backoff(attempt: number): Promise { + const delay = Math.min(200 * Math.pow(2, attempt), 5_000); + return new Promise((resolve) => setTimeout(resolve, delay)); + } + + /** Returns true for AbortError / network errors that are safe to retry. */ + private isRetryableError(err: unknown): boolean { + if (err instanceof DOMException && err.name === 'AbortError') return true; + if (err instanceof TypeError) return true; // network errors + return false; } /** Fetch a developer's profile + on-chain stats, or null if not found. */