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
19 changes: 19 additions & 0 deletions docs/api/trust-graph.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand All @@ -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"

Expand Down Expand Up @@ -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
Expand All @@ -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"

Expand Down Expand Up @@ -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:
Expand Down
20 changes: 18 additions & 2 deletions services/trust-graph/src/routes/trust.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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()
Expand All @@ -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)
}
})

Expand All @@ -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)
}
})

Expand Down Expand Up @@ -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' }
}
11 changes: 9 additions & 2 deletions services/trust-graph/src/services/trust-service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -206,6 +206,8 @@ export class TrustService {
transitiveTrusters: number
lastComputed: string
}> {
await this.ensureIdentity(db, did)

// Check cache first
const cached = await db
.select()
Expand Down Expand Up @@ -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 {
Expand All @@ -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) {
Expand Down Expand Up @@ -345,6 +351,7 @@ export class TrustService {
* Get capability-specific score for a DID.
*/
async getCapabilityScore(db: DbClient, did: string, capabilityId: string): Promise<CapabilityScoreResult> {
await this.ensureIdentity(db, did)
return computeCapabilityScore(db, did, capabilityId)
}

Expand Down
65 changes: 63 additions & 2 deletions services/trust-graph/test/routes.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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) {
Expand Down Expand Up @@ -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([])
}),
})),
Expand All @@ -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(() => ({
Expand Down
15 changes: 11 additions & 4 deletions services/trust-graph/test/trust-service.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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([
{
Expand All @@ -262,10 +270,9 @@ describe('TrustService', () => {
},
])),
}
} else {
// Second call: edges for recomputation
return Promise.resolve([])
}
// Third call: edges for recomputation
return Promise.resolve([])
}),
})),
}))
Expand Down