Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
41 changes: 39 additions & 2 deletions src/accessControl.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,15 @@ export interface AsyncAclStore {
check(resourceId: string, address: string): Promise<boolean>;
}

export interface AclManagerOptions {
cacheTtlMs?: number;
}

interface CacheEntry {
value: boolean;
expiresAt: number;
}

class InMemoryAclStore implements AsyncAclStore {
private grants = new Map<string, Set<string>>();

Expand All @@ -36,9 +45,12 @@ class InMemoryAclStore implements AsyncAclStore {
*/
export class AclManager {
private readonly store: AsyncAclStore;
private readonly cacheTtlMs: number;
private readonly cache = new Map<string, CacheEntry>();

constructor(store?: AsyncAclStore) {
constructor(store?: AsyncAclStore, options: AclManagerOptions = {}) {
this.store = store ?? new InMemoryAclStore();
this.cacheTtlMs = options.cacheTtlMs ?? 60_000;
}

/**
Expand All @@ -49,6 +61,7 @@ export class AclManager {
*/
async grant(resourceId: string, address: string): Promise<void> {
await this.store.grant(resourceId, address);
this.invalidateCache(address);
}

/**
Expand All @@ -59,6 +72,7 @@ export class AclManager {
*/
async revoke(resourceId: string, address: string): Promise<void> {
await this.store.revoke(resourceId, address);
this.invalidateCache(address);
}

/**
Expand All @@ -69,6 +83,29 @@ export class AclManager {
* @returns True if access is granted
*/
async check(resourceId: string, address: string): Promise<boolean> {
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}`;
}
}
17 changes: 17 additions & 0 deletions src/dependencyGraphValidator.ts
Original file line number Diff line number Diff line change
@@ -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[][];
Expand All @@ -15,12 +26,14 @@ export function validateDependencyGraph(invoices: Invoice[]): ValidationResult {
// adjacency: id -> prerequisite ids that exist
const adj = new Map<string, string[]>();
const warnings: string[] = [];
const missingNodeIds = new Set<string>();

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);
}
Expand Down Expand Up @@ -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 };
}
3 changes: 3 additions & 0 deletions src/retryPolicy.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<RetryOptions>;
Expand Down Expand Up @@ -47,6 +48,7 @@ export async function executeWithRetry<T>(
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;
Expand All @@ -59,6 +61,7 @@ export async function executeWithRetry<T>(

const isLast = attempt === maxAttempts - 1;
if (!isRetryable(error)) break;
if (error instanceof Error && retryIf && !retryIf(error)) break;
if (isLast) {
attemptsExhausted = true;
break;
Expand Down
17 changes: 15 additions & 2 deletions src/topology.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand All @@ -19,6 +20,12 @@ export async function discoverRPCNodes(
}

async function fetchNodeList(discoveryUrl: string): Promise<string[]> {
/**
* 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();
Expand All @@ -38,6 +45,12 @@ async function fetchNodeList(discoveryUrl: string): Promise<string[]> {
}

async function pingNode(url: string): Promise<RPCNode> {
/**
* 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) });
Expand All @@ -46,4 +59,4 @@ async function pingNode(url: string): Promise<RPCNode> {
} catch {
return { url, latencyMs: Date.now() - start, healthy: false };
}
}
}
37 changes: 37 additions & 0 deletions test/accessControl.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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);
});
});
11 changes: 4 additions & 7 deletions test/dependencyGraphValidator.test.ts
Original file line number Diff line number Diff line change
@@ -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 {
Expand Down Expand Up @@ -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);
});
});
14 changes: 14 additions & 0 deletions test/retryPolicy.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand Down