diff --git a/src/accessControl.ts b/src/accessControl.ts index 339534e..7c7bed2 100644 --- a/src/accessControl.ts +++ b/src/accessControl.ts @@ -10,6 +10,15 @@ export interface AsyncAclStore { check(resourceId: string, address: string): Promise; } +export interface AclManagerOptions { + cacheTtlMs?: number; +} + +interface CacheEntry { + value: boolean; + expiresAt: number; +} + class InMemoryAclStore implements AsyncAclStore { private grants = new Map>(); @@ -36,9 +45,12 @@ class InMemoryAclStore implements AsyncAclStore { */ export class AclManager { private readonly store: AsyncAclStore; + private readonly cacheTtlMs: number; + private readonly cache = new Map(); - constructor(store?: AsyncAclStore) { + constructor(store?: AsyncAclStore, options: AclManagerOptions = {}) { this.store = store ?? new InMemoryAclStore(); + this.cacheTtlMs = options.cacheTtlMs ?? 60_000; } /** @@ -49,6 +61,7 @@ export class AclManager { */ async grant(resourceId: string, address: string): Promise { await this.store.grant(resourceId, address); + this.invalidateCache(address); } /** @@ -59,6 +72,7 @@ export class AclManager { */ async revoke(resourceId: string, address: string): Promise { await this.store.revoke(resourceId, address); + this.invalidateCache(address); } /** @@ -69,6 +83,29 @@ export class AclManager { * @returns True if access is granted */ async check(resourceId: string, address: string): Promise { - return this.store.check(resourceId, address); + const key = this.cacheKey(resourceId, address); + const cached = this.cache.get(key); + if (cached && cached.expiresAt > Date.now()) { + return cached.value; + } + + const allowed = await this.store.check(resourceId, address); + this.cache.set(key, { + value: allowed, + expiresAt: Date.now() + this.cacheTtlMs, + }); + return allowed; + } + + invalidateCache(principal: string): void { + for (const key of this.cache.keys()) { + if (key.endsWith(`:${principal}`)) { + this.cache.delete(key); + } + } + } + + private cacheKey(resourceId: string, address: string): string { + return `${resourceId}:${address}`; } } diff --git a/src/dependencyGraphValidator.ts b/src/dependencyGraphValidator.ts index b37d676..aa76dcd 100644 --- a/src/dependencyGraphValidator.ts +++ b/src/dependencyGraphValidator.ts @@ -1,5 +1,16 @@ import type { Invoice } from "./types.js"; +export class MissingNodeError extends Error { + readonly missingNodeIds: string[]; + + constructor(missingNodeIds: string[]) { + super(`Missing dependency nodes: ${missingNodeIds.join(", ")}`); + this.name = "MissingNodeError"; + this.missingNodeIds = missingNodeIds; + Object.setPrototypeOf(this, new.target.prototype); + } +} + export interface ValidationResult { valid: boolean; cycles: string[][]; @@ -15,12 +26,14 @@ export function validateDependencyGraph(invoices: Invoice[]): ValidationResult { // adjacency: id -> prerequisite ids that exist const adj = new Map(); const warnings: string[] = []; + const missingNodeIds = new Set(); for (const inv of invoices) { const deps: string[] = []; for (const pre of inv.prerequisites ?? []) { if (!ids.has(pre)) { warnings.push(`Invoice "${inv.id}" references unknown prerequisite "${pre}"`); + missingNodeIds.add(pre); } else { deps.push(pre); } @@ -55,5 +68,9 @@ export function validateDependencyGraph(invoices: Invoice[]): ValidationResult { if (!visited.has(id)) dfs(id, []); } + if (missingNodeIds.size > 0) { + throw new MissingNodeError(Array.from(missingNodeIds)); + } + return { valid: cycles.length === 0, cycles, warnings }; } diff --git a/src/retryPolicy.ts b/src/retryPolicy.ts index 4bbac52..9aebd64 100644 --- a/src/retryPolicy.ts +++ b/src/retryPolicy.ts @@ -10,6 +10,7 @@ export interface RetryOptions { baseDelayMs: number; maxDelayMs: number; onRetry?: (attempt: number, error: unknown, delayMs: number) => void; + retryIf?: (error: Error) => boolean; } export type PerMethodRetryOptions = Partial; @@ -47,6 +48,7 @@ export async function executeWithRetry( const baseDelayMs = methodOverride?.baseDelayMs ?? options.baseDelayMs; const maxDelayMs = methodOverride?.maxDelayMs ?? options.maxDelayMs; const onRetry = methodOverride?.onRetry ?? options.onRetry; + const retryIf = methodOverride?.retryIf ?? options.retryIf; let lastError: unknown; let attemptsExhausted = false; @@ -59,6 +61,7 @@ export async function executeWithRetry( const isLast = attempt === maxAttempts - 1; if (!isRetryable(error)) break; + if (error instanceof Error && retryIf && !retryIf(error)) break; if (isLast) { attemptsExhausted = true; break; diff --git a/src/topology.ts b/src/topology.ts index 5fccffc..c4bce1d 100644 --- a/src/topology.ts +++ b/src/topology.ts @@ -4,7 +4,8 @@ import { DiscoveryFetchError } from "./errors.js"; const DEFAULT_DISCOVERY_URL = "https://horizon.stellar.org/network_info"; /** - * Fetch the Stellar node list and benchmark each endpoint by latency. + * Fetch the Stellar RPC node list from a discovery endpoint and benchmark each + * endpoint by latency before returning the sorted health view. * * @param discoveryUrl - URL that returns a JSON array of RPC node URLs. * Defaults to the public Horizon network_info endpoint. @@ -19,6 +20,12 @@ export async function discoverRPCNodes( } async function fetchNodeList(discoveryUrl: string): Promise { + /** + * Load candidate RPC node URLs from a supported discovery payload. + * + * @param discoveryUrl - Endpoint returning either a raw string array or an object with node arrays. + * @returns Normalized list of string RPC URLs. + */ const res = await fetch(discoveryUrl); if (!res.ok) throw new DiscoveryFetchError(res.status, res.statusText); const data: unknown = await res.json(); @@ -38,6 +45,12 @@ async function fetchNodeList(discoveryUrl: string): Promise { } async function pingNode(url: string): Promise { + /** + * Measure one node's responsiveness and health by timing a simple GET request. + * + * @param url - RPC endpoint URL to probe. + * @returns Node metadata with latency and health flags. + */ const start = Date.now(); try { const res = await fetch(url, { method: "GET", signal: AbortSignal.timeout(5000) }); @@ -46,4 +59,4 @@ async function pingNode(url: string): Promise { } catch { return { url, latencyMs: Date.now() - start, healthy: false }; } -} \ No newline at end of file +} diff --git a/test/accessControl.test.ts b/test/accessControl.test.ts index 28bd3fc..5ad7f0a 100644 --- a/test/accessControl.test.ts +++ b/test/accessControl.test.ts @@ -62,4 +62,41 @@ describe("AclManager", () => { expect(await manager.check(resourceId, address1)).toBe(false); expect(await manager.check(resourceId, address2)).toBe(true); }); + + it("caches permission checks per resource and principal until ttl expiry", async () => { + vi.useFakeTimers(); + const mockStore: AsyncAclStore = { + grant: vi.fn().mockResolvedValue(undefined), + revoke: vi.fn().mockResolvedValue(undefined), + check: vi.fn().mockResolvedValue(true), + }; + + const manager = new AclManager(mockStore, { cacheTtlMs: 100 }); + + await manager.check("invoice-001", "GAAA"); + await manager.check("invoice-001", "GAAA"); + expect(mockStore.check).toHaveBeenCalledTimes(1); + + await vi.advanceTimersByTimeAsync(101); + await manager.check("invoice-001", "GAAA"); + expect(mockStore.check).toHaveBeenCalledTimes(2); + + vi.useRealTimers(); + }); + + it("invalidates cached entries for a principal", async () => { + const mockStore: AsyncAclStore = { + grant: vi.fn().mockResolvedValue(undefined), + revoke: vi.fn().mockResolvedValue(undefined), + check: vi.fn().mockResolvedValue(true), + }; + + const manager = new AclManager(mockStore); + + await manager.check("invoice-001", "GAAA"); + manager.invalidateCache("GAAA"); + await manager.check("invoice-001", "GAAA"); + + expect(mockStore.check).toHaveBeenCalledTimes(2); + }); }); diff --git a/test/dependencyGraphValidator.test.ts b/test/dependencyGraphValidator.test.ts index 4c2780a..1e0cbec 100644 --- a/test/dependencyGraphValidator.test.ts +++ b/test/dependencyGraphValidator.test.ts @@ -1,5 +1,5 @@ import { describe, it, expect } from "vitest"; -import { validateDependencyGraph } from "../src/dependencyGraphValidator.js"; +import { MissingNodeError, validateDependencyGraph } from "../src/dependencyGraphValidator.js"; import type { Invoice } from "../src/types.js"; function inv(id: string, prerequisites: string[] = []): Invoice { @@ -41,17 +41,14 @@ describe("validateDependencyGraph", () => { expect(flat).toContain("B"); }); - it("warns on dangling prerequisite references without failing", () => { - const result = validateDependencyGraph([inv("A", ["MISSING"])]); - expect(result.valid).toBe(true); - expect(result.cycles).toHaveLength(0); - expect(result.warnings).toHaveLength(1); - expect(result.warnings[0]).toMatch(/MISSING/); + it("throws when a graph references a missing node", () => { + expect(() => validateDependencyGraph([inv("A", ["MISSING"])])).toThrow(MissingNodeError); }); it("handles invoices with no prerequisites", () => { const result = validateDependencyGraph([inv("X"), inv("Y"), inv("Z")]); expect(result.valid).toBe(true); expect(result.cycles).toHaveLength(0); + expect(result.warnings).toHaveLength(0); }); }); diff --git a/test/retryPolicy.test.ts b/test/retryPolicy.test.ts index b9dc655..6c9cb3c 100644 --- a/test/retryPolicy.test.ts +++ b/test/retryPolicy.test.ts @@ -237,6 +237,20 @@ describe("executeWithRetry", () => { expect(onRetry).toHaveBeenNthCalledWith(2, 2, err, expect.any(Number)); }); + it("uses retryIf to block retries for selected errors", async () => { + const err = new ValidationError("bad input"); + const fn = vi.fn().mockRejectedValue(err); + + await expect( + executeWithRetry(fn, { + ...defaultOptions, + retryIf: (error) => !(error instanceof ValidationError), + }), + ).rejects.toThrow(ValidationError); + + expect(fn).toHaveBeenCalledTimes(1); + }); + it("respects per-method maxAttempts override", async () => { const err = new Error("network timeout"); const fn = vi.fn().mockRejectedValue(err);