diff --git a/docs/api/trust-graph.yaml b/docs/api/trust-graph.yaml index ced6caa..692c1a4 100644 --- a/docs/api/trust-graph.yaml +++ b/docs/api/trust-graph.yaml @@ -87,6 +87,8 @@ paths: description: | Computes a reputation score based on direct and transitive trust edges. Algorithm: direct × 1.0 + depth-2 × 0.5 + depth-3+ × 0.25. + The DID is resolved through discovery before scores are computed so + unknown identities fail before reputation cache writes. parameters: - $ref: "#/components/parameters/DidParam" responses: @@ -96,6 +98,10 @@ paths: application/json: schema: $ref: "#/components/schemas/ReputationScore" + "404": + $ref: "#/components/responses/NotFound" + "503": + $ref: "#/components/responses/ServiceUnavailable" "500": $ref: "#/components/responses/InternalError" @@ -132,6 +138,9 @@ paths: operationId: getCapabilityScore tags: [Trust] summary: Get capability-specific trust score + description: | + Resolves the DID through discovery before computing capability trust. + Unknown identities return `404`; discovery outages return `503`. parameters: - $ref: "#/components/parameters/DidParam" - name: capabilityId @@ -147,6 +156,10 @@ paths: application/json: schema: $ref: "#/components/schemas/CapabilityScore" + "404": + $ref: "#/components/responses/NotFound" + "503": + $ref: "#/components/responses/ServiceUnavailable" "500": $ref: "#/components/responses/InternalError" @@ -589,6 +602,12 @@ components: application/json: schema: $ref: "#/components/schemas/ErrorResponse" + ServiceUnavailable: + description: Upstream dependency unavailable + content: + application/json: + schema: + $ref: "#/components/schemas/ErrorResponse" RateLimited: description: Too many requests content: diff --git a/services/trust-graph/src/routes/trust.ts b/services/trust-graph/src/routes/trust.ts index 11d5284..66ca0aa 100644 --- a/services/trust-graph/src/routes/trust.ts +++ b/services/trust-graph/src/routes/trust.ts @@ -3,6 +3,7 @@ import type { DbClient } from '../db/client.js' import { TrustService } from '../services/trust-service.js' import type { CreateTrustRequest } from '../types.js' import { apiKeyAuth, TRUST_GRAPH_API_SCOPES } from '../middleware/auth.js' +import { TrustError } from '@fides/shared' export function createTrustRoutes(db: DbClient, discoveryUrl?: string) { const app = new Hono() @@ -29,7 +30,8 @@ export function createTrustRoutes(db: DbClient, discoveryUrl?: string) { c.header('Cache-Control', 'public, max-age=300') return c.json(score) } catch (error) { - return c.json({ error: 'Internal server error' }, 500) + const mapped = mapScoreLookupError(error) + return c.json({ error: mapped.message }, mapped.status) } }) @@ -55,7 +57,8 @@ export function createTrustRoutes(db: DbClient, discoveryUrl?: string) { c.header('Cache-Control', 'public, max-age=300') return c.json(score) } catch (error) { - return c.json({ error: 'Internal server error' }, 500) + const mapped = mapScoreLookupError(error) + return c.json({ error: mapped.message }, mapped.status) } }) @@ -123,3 +126,16 @@ export function createTrustRoutes(db: DbClient, discoveryUrl?: string) { return app } + +function mapScoreLookupError(error: unknown): { status: 404 | 500 | 503; message: string } { + if (error instanceof TrustError) { + if (error.message.startsWith('Discovery service unavailable')) { + return { status: 503, message: error.message } + } + if (error.message.startsWith('Identity not found')) { + return { status: 404, message: error.message } + } + } + + return { status: 500, message: 'Internal server error' } +} diff --git a/services/trust-graph/src/services/trust-service.ts b/services/trust-graph/src/services/trust-service.ts index d41e249..8afde13 100644 --- a/services/trust-graph/src/services/trust-service.ts +++ b/services/trust-graph/src/services/trust-service.ts @@ -206,6 +206,8 @@ export class TrustService { transitiveTrusters: number lastComputed: string }> { + await this.ensureIdentity(db, did) + // Check cache first const cached = await db .select() @@ -294,7 +296,7 @@ export class TrustService { const circuitOpen = Date.now() < this.circuitBreaker.openUntil if (circuitOpen) { - throw new TrustError(`Identity not found: ${did}. Discovery service circuit breaker open.`) + throw new TrustError(`Discovery service unavailable while resolving identity: ${did}`) } try { @@ -311,13 +313,17 @@ export class TrustService { identity = await response.json() // Reset circuit breaker on success this.circuitBreaker.failureCount = 0 + } else if (response.status >= 500) { + throw new TrustError(`Discovery service unavailable while resolving identity: ${did}`) } - } catch { + } catch (error) { + if (error instanceof TrustError) throw error // Discovery service unavailable — increment circuit breaker this.circuitBreaker.failureCount++ if (this.circuitBreaker.failureCount >= CIRCUIT_BREAKER_THRESHOLD) { this.circuitBreaker.openUntil = Date.now() + CIRCUIT_BREAKER_RESET_MS } + throw new TrustError(`Discovery service unavailable while resolving identity: ${did}`) } if (!identity || !identity.publicKey) { @@ -345,6 +351,7 @@ export class TrustService { * Get capability-specific score for a DID. */ async getCapabilityScore(db: DbClient, did: string, capabilityId: string): Promise { + await this.ensureIdentity(db, did) return computeCapabilityScore(db, did, capabilityId) } diff --git a/services/trust-graph/test/routes.test.ts b/services/trust-graph/test/routes.test.ts index b451cd9..b9993bb 100644 --- a/services/trust-graph/test/routes.test.ts +++ b/services/trust-graph/test/routes.test.ts @@ -50,6 +50,7 @@ describe('HTTP Routes', () => { restoreEnv('SERVICE_API_KEY', ORIGINAL_SERVICE_API_KEY) restoreEnv('TRUST_GRAPH_API_KEYS', ORIGINAL_TRUST_GRAPH_API_KEYS) restoreEnv('NODE_ENV', ORIGINAL_NODE_ENV) + vi.unstubAllGlobals() }) function mockIdentity(publicKey: Uint8Array) { @@ -260,12 +261,18 @@ describe('HTTP Routes', () => { where: vi.fn(() => { selectCallCount++ if (selectCallCount === 1) { - // First call: cache check (empty = cache miss) + // First call: identity lookup + return { + limit: vi.fn(() => Promise.resolve([{ publicKey: Buffer.from('01'.repeat(32), 'hex') }])), + } + } + if (selectCallCount === 2) { + // Second call: cache check (empty = cache miss) return { limit: vi.fn(() => Promise.resolve([])), } } - // Second call: edges query (no limit, returns array directly) + // Third call: edges query (no limit, returns array directly) return Promise.resolve([]) }), })), @@ -288,6 +295,60 @@ describe('HTTP Routes', () => { expect(json.transitiveTrusters).toBeDefined() }) + it('GET /v1/trust/:did/score should return 404 when identity is absent from discovery', async () => { + vi.stubGlobal('fetch', vi.fn(() => Promise.resolve(new Response('not found', { status: 404 })))) + + mockDb.select = vi.fn(() => ({ + from: vi.fn(() => ({ + where: vi.fn(() => ({ + limit: vi.fn(() => Promise.resolve([])), + })), + })), + })) + + const app = createTrustRoutes(mockDb, 'http://discovery.test') + const res = await app.request('/v1/trust/did:fides:missing/score') + + expect(res.status).toBe(404) + expect((await res.json()).error).toContain('Identity not found: did:fides:missing') + }) + + it('GET /v1/trust/:did/score should return 503 when discovery is unavailable', async () => { + vi.stubGlobal('fetch', vi.fn(() => Promise.resolve(new Response('unavailable', { status: 503 })))) + + mockDb.select = vi.fn(() => ({ + from: vi.fn(() => ({ + where: vi.fn(() => ({ + limit: vi.fn(() => Promise.resolve([])), + })), + })), + })) + + const app = createTrustRoutes(mockDb, 'http://discovery.test') + const res = await app.request('/v1/trust/did:fides:missing/score') + + expect(res.status).toBe(503) + expect((await res.json()).error).toContain('Discovery service unavailable') + }) + + it('GET /v1/trust/:did/capability/:capabilityId should return 404 when identity is absent from discovery', async () => { + vi.stubGlobal('fetch', vi.fn(() => Promise.resolve(new Response('not found', { status: 404 })))) + + mockDb.select = vi.fn(() => ({ + from: vi.fn(() => ({ + where: vi.fn(() => ({ + limit: vi.fn(() => Promise.resolve([])), + })), + })), + })) + + const app = createTrustRoutes(mockDb, 'http://discovery.test') + const res = await app.request('/v1/trust/did:fides:missing/capability/payments.execute') + + expect(res.status).toBe(404) + expect((await res.json()).error).toContain('Identity not found: did:fides:missing') + }) + it('GET /v1/trust/:from/:to should return trust path', async () => { // Mock edges query mockDb.select = vi.fn(() => ({ diff --git a/services/trust-graph/test/trust-service.test.ts b/services/trust-graph/test/trust-service.test.ts index 4214dcf..c112aad 100644 --- a/services/trust-graph/test/trust-service.test.ts +++ b/services/trust-graph/test/trust-service.test.ts @@ -250,7 +250,15 @@ describe('TrustService', () => { where: vi.fn(() => { selectCallCount++ if (selectCallCount === 1) { - // First call: cached score (expired) + // First call: identity lookup + return { + limit: vi.fn(() => Promise.resolve([ + { did: 'did:fides:alice', publicKey: Buffer.from('01'.repeat(32), 'hex') }, + ])), + } + } + if (selectCallCount === 2) { + // Second call: cached score (expired) return { limit: vi.fn(() => Promise.resolve([ { @@ -262,10 +270,9 @@ describe('TrustService', () => { }, ])), } - } else { - // Second call: edges for recomputation - return Promise.resolve([]) } + // Third call: edges for recomputation + return Promise.resolve([]) }), })), }))