From 8aa246a2b8a7444b8cb9228d10b9a466709537f8 Mon Sep 17 00:00:00 2001 From: Yusufolosun Date: Mon, 1 Jun 2026 20:37:16 +0100 Subject: [PATCH] fix: enforce provider allowlist for metadata sources --- backend/src/api/routes/index.ts | 8 + .../api/routes/providerAllowlist.routes.ts | 66 ++++++ .../routes/providerAllowlistAdmin.routes.ts | 105 ++++++++++ .../migrations/027_provider_allowlist.ts | 22 ++ backend/src/services/audit.service.ts | 4 +- .../src/services/providerAllowlist.service.ts | 198 ++++++++++++++++++ backend/src/services/sources/circle.source.ts | 11 + .../sources/coingecko-metadata.source.ts | 8 + .../src/services/sources/coingecko.source.ts | 8 + .../services/sources/coinmarketcap.source.ts | 8 + backend/src/services/sources/dex.source.ts | 33 ++- .../sources/stellar-expert-metadata.source.ts | 9 + docs/asset-metadata-sync.md | 19 ++ 13 files changed, 495 insertions(+), 4 deletions(-) create mode 100644 backend/src/api/routes/providerAllowlist.routes.ts create mode 100644 backend/src/api/routes/providerAllowlistAdmin.routes.ts create mode 100644 backend/src/database/migrations/027_provider_allowlist.ts create mode 100644 backend/src/services/providerAllowlist.service.ts diff --git a/backend/src/api/routes/index.ts b/backend/src/api/routes/index.ts index 65f6991a..faecef01 100644 --- a/backend/src/api/routes/index.ts +++ b/backend/src/api/routes/index.ts @@ -49,6 +49,8 @@ import { eventSubscriptionFilterRoutes } from "./eventSubscriptionFilter.routes. import { maintenanceRoutes } from "./maintenance.js"; import { notificationTemplatesRoutes } from "./notificationTemplates.js"; import { archivedDataBrowserRoutes } from "./archivedDataBrowser.routes.js"; +import { providerAllowlistRoutes } from "./providerAllowlist.routes.js"; +import { providerAllowlistAdminRoutes } from "./providerAllowlistAdmin.routes.js"; export async function registerRoutes(server: FastifyInstance) { server.register(assetsRoutes, { prefix: "/api/v1/assets" }); @@ -122,4 +124,10 @@ export async function registerRoutes(server: FastifyInstance) { prefix: "/api/v1/notification-templates", }); server.register(archivedDataBrowserRoutes, { prefix: "/api/v1/archive" }); + server.register(providerAllowlistRoutes, { + prefix: "/api/v1/providers/allowlist", + }); + server.register(providerAllowlistAdminRoutes, { + prefix: "/api/v1/admin/providers/allowlist", + }); } diff --git a/backend/src/api/routes/providerAllowlist.routes.ts b/backend/src/api/routes/providerAllowlist.routes.ts new file mode 100644 index 00000000..a7da652f --- /dev/null +++ b/backend/src/api/routes/providerAllowlist.routes.ts @@ -0,0 +1,66 @@ +import type { FastifyInstance, FastifyReply, FastifyRequest } from "fastify"; +import { providerAllowlistService } from "../../services/providerAllowlist.service.js"; + +export async function providerAllowlistRoutes(server: FastifyInstance) { + server.get( + "/", + { + schema: { + tags: ["Providers"], + summary: "List provider allowlist entries", + response: { + 200: { + type: "object", + properties: { + enforcement: { type: "string", enum: ["open", "allowlist"] }, + total: { type: "integer" }, + entries: { type: "array", items: { type: "object", additionalProperties: true } }, + }, + }, + }, + }, + }, + async (_request: FastifyRequest, reply: FastifyReply) => { + const entries = await providerAllowlistService.listEntries(); + const enforcement = entries.length > 0 ? "allowlist" : "open"; + return reply.send({ enforcement, total: entries.length, entries }); + } + ); + + server.get<{ Params: { providerKey: string } }>( + "/:providerKey", + { + schema: { + tags: ["Providers"], + summary: "Lookup provider allowlist status", + params: { + type: "object", + required: ["providerKey"], + properties: { + providerKey: { type: "string" }, + }, + }, + response: { + 200: { + type: "object", + properties: { + providerKey: { type: "string" }, + allowed: { type: "boolean" }, + enforcement: { type: "string", enum: ["open", "allowlist"] }, + entry: { type: "object", nullable: true, additionalProperties: true }, + }, + }, + }, + }, + }, + async (request: FastifyRequest<{ Params: { providerKey: string } }>, reply: FastifyReply) => { + const { providerKey } = request.params; + const [entry, allowed] = await Promise.all([ + providerAllowlistService.getEntry(providerKey), + providerAllowlistService.isAllowed(providerKey), + ]); + const enforcement = allowed && !entry ? "open" : "allowlist"; + return reply.send({ providerKey: providerKey.toLowerCase(), allowed, enforcement, entry }); + } + ); +} diff --git a/backend/src/api/routes/providerAllowlistAdmin.routes.ts b/backend/src/api/routes/providerAllowlistAdmin.routes.ts new file mode 100644 index 00000000..3f47bb20 --- /dev/null +++ b/backend/src/api/routes/providerAllowlistAdmin.routes.ts @@ -0,0 +1,105 @@ +import type { FastifyInstance, FastifyReply, FastifyRequest } from "fastify"; +import { z } from "zod"; +import { authMiddleware } from "../middleware/auth.js"; +import { providerAllowlistService } from "../../services/providerAllowlist.service.js"; + +const providerKeySchema = z.string().min(1).max(64).regex(/^[a-z0-9][a-z0-9-]*$/i); + +const upsertBodySchema = z.object({ + displayName: z.string().trim().min(1).max(120).optional(), + category: z.string().trim().min(1).max(80).optional(), + allowed: z.boolean(), + reason: z.string().trim().max(500).optional(), +}); + +export async function providerAllowlistAdminRoutes(server: FastifyInstance) { + const requireAdmin = authMiddleware({ requiredScopes: ["admin:config"] }); + + server.put<{ Params: { providerKey: string }; Body: z.infer }>( + "/:providerKey", + { + preHandler: requireAdmin, + schema: { + tags: ["Config"], + summary: "Create or update a provider allowlist entry", + params: { + type: "object", + required: ["providerKey"], + properties: { providerKey: { type: "string" } }, + }, + body: { + type: "object", + required: ["allowed"], + properties: { + displayName: { type: "string" }, + category: { type: "string" }, + allowed: { type: "boolean" }, + reason: { type: "string" }, + }, + }, + response: { + 200: { type: "object", properties: { entry: { type: "object", additionalProperties: true } } }, + 201: { type: "object", properties: { entry: { type: "object", additionalProperties: true } } }, + }, + }, + }, + async (request: FastifyRequest<{ Params: { providerKey: string }; Body: z.infer }>, reply: FastifyReply) => { + const providerKey = providerKeySchema.parse(request.params.providerKey); + const body = upsertBodySchema.parse(request.body); + + const existing = await providerAllowlistService.getEntry(providerKey); + + const entry = await providerAllowlistService.upsertEntry({ + providerKey, + displayName: body.displayName, + category: body.category, + allowed: body.allowed, + reason: body.reason ?? null, + actorId: request.apiKeyAuth?.id ?? request.apiKeyAuth?.name ?? "admin", + actorType: "api_key", + ipAddress: request.ip, + userAgent: request.headers["user-agent"] as string | undefined, + }); + + const status = existing ? 200 : 201; + return reply.code(status).send({ entry }); + } + ); + + server.delete<{ Params: { providerKey: string } }>( + "/:providerKey", + { + preHandler: requireAdmin, + schema: { + tags: ["Config"], + summary: "Delete a provider allowlist entry", + params: { + type: "object", + required: ["providerKey"], + properties: { providerKey: { type: "string" } }, + }, + response: { + 200: { type: "object", properties: { deleted: { type: "boolean" } } }, + 404: { $ref: "Error#" }, + }, + }, + }, + async (request: FastifyRequest<{ Params: { providerKey: string } }>, reply: FastifyReply) => { + const providerKey = providerKeySchema.parse(request.params.providerKey); + + const deleted = await providerAllowlistService.deleteEntry({ + providerKey, + actorId: request.apiKeyAuth?.id ?? request.apiKeyAuth?.name ?? "admin", + actorType: "api_key", + ipAddress: request.ip, + userAgent: request.headers["user-agent"] as string | undefined, + }); + + if (!deleted) { + return reply.code(404).send({ error: "Provider allowlist entry not found" }); + } + + return reply.send({ deleted: true }); + } + ); +} diff --git a/backend/src/database/migrations/027_provider_allowlist.ts b/backend/src/database/migrations/027_provider_allowlist.ts new file mode 100644 index 00000000..09913bd7 --- /dev/null +++ b/backend/src/database/migrations/027_provider_allowlist.ts @@ -0,0 +1,22 @@ +import type { Knex } from "knex"; + +export async function up(knex: Knex): Promise { + await knex.schema.createTable("provider_allowlist", (table) => { + table.string("provider_key").primary(); + table.string("display_name").notNullable(); + table.string("category").notNullable().defaultTo("unknown"); + table.boolean("allowed").notNullable().defaultTo(true); + table.text("reason").nullable(); + table.string("created_by").notNullable(); + table.timestamp("created_at").notNullable().defaultTo(knex.fn.now()); + table.string("updated_by").notNullable(); + table.timestamp("updated_at").notNullable().defaultTo(knex.fn.now()); + + table.index(["category"]); + table.index(["allowed"]); + }); +} + +export async function down(knex: Knex): Promise { + await knex.schema.dropTableIfExists("provider_allowlist"); +} diff --git a/backend/src/services/audit.service.ts b/backend/src/services/audit.service.ts index a1a535d3..c2575470 100644 --- a/backend/src/services/audit.service.ts +++ b/backend/src/services/audit.service.ts @@ -15,6 +15,7 @@ export type AuditAction = | "data.updated" | "data.deleted" | "admin.config_changed" + | "admin.provider_allowlist_changed" | "admin.user_permission_changed" | "admin.retention_policy_changed" | "alert.rule_created" @@ -327,7 +328,8 @@ export class AuditService { action === "admin.user_permission_changed" || action === "auth.api_key_revoked" || action === "webhook.secret_rotated" || - action === "admin.config_changed" + action === "admin.config_changed" || + action === "admin.provider_allowlist_changed" ) return "warning"; if (action === "admin.retention_policy_changed") return "critical"; diff --git a/backend/src/services/providerAllowlist.service.ts b/backend/src/services/providerAllowlist.service.ts new file mode 100644 index 00000000..661c5fd2 --- /dev/null +++ b/backend/src/services/providerAllowlist.service.ts @@ -0,0 +1,198 @@ +import { getDatabase } from "../database/connection.js"; +import { logger } from "../utils/logger.js"; +import { auditService } from "./audit.service.js"; + +export interface ProviderAllowlistEntry { + providerKey: string; + displayName: string; + category: string; + allowed: boolean; + reason: string | null; + createdBy: string; + createdAt: Date; + updatedBy: string; + updatedAt: Date; +} + +interface UpsertInput { + providerKey: string; + displayName?: string | null; + category?: string | null; + allowed: boolean; + reason?: string | null; + actorId: string; + actorType?: "user" | "api_key" | "system"; + ipAddress?: string | null; + userAgent?: string | null; +} + +function normalizeProviderKey(raw: string): string { + return raw.trim().toLowerCase(); +} + +function mapEntry(row: Record): ProviderAllowlistEntry { + return { + providerKey: String(row.provider_key), + displayName: String(row.display_name), + category: String(row.category), + allowed: Boolean(row.allowed), + reason: row.reason ? String(row.reason) : null, + createdBy: String(row.created_by), + createdAt: row.created_at instanceof Date ? row.created_at : new Date(String(row.created_at)), + updatedBy: String(row.updated_by), + updatedAt: row.updated_at instanceof Date ? row.updated_at : new Date(String(row.updated_at)), + }; +} + +export class ProviderAllowlistService { + private db = getDatabase(); + + async listEntries(): Promise { + const rows = await this.db("provider_allowlist") + .select("*") + .orderBy("provider_key", "asc"); + return rows.map(mapEntry); + } + + async getEntry(providerKey: string): Promise { + const key = normalizeProviderKey(providerKey); + const row = await this.db("provider_allowlist") + .where({ provider_key: key }) + .first(); + return row ? mapEntry(row) : null; + } + + async isAllowed(providerKey: string): Promise { + const key = normalizeProviderKey(providerKey); + if (!key) return false; + + try { + const anyEntry = await this.db("provider_allowlist") + .first("provider_key"); + if (!anyEntry) { + return true; + } + + const row = await this.db("provider_allowlist") + .where({ provider_key: key }) + .first(); + + return row ? Boolean(row.allowed) : false; + } catch (error) { + logger.warn({ error, providerKey: key }, "Provider allowlist check failed; allowing by default"); + return true; + } + } + + async upsertEntry(input: UpsertInput): Promise { + const key = normalizeProviderKey(input.providerKey); + const now = new Date(); + + const existing = await this.db("provider_allowlist") + .where({ provider_key: key }) + .first(); + + if (existing) { + const [row] = await this.db("provider_allowlist") + .where({ provider_key: key }) + .update({ + display_name: input.displayName ?? existing.display_name, + category: input.category ?? existing.category, + allowed: input.allowed, + reason: input.reason ?? existing.reason ?? null, + updated_by: input.actorId, + updated_at: now, + }) + .returning("*"); + + const updated = mapEntry(row); + + await auditService.log({ + action: "admin.provider_allowlist_changed", + actorId: input.actorId, + actorType: input.actorType ?? "api_key", + ipAddress: input.ipAddress ?? undefined, + userAgent: input.userAgent ?? undefined, + resourceType: "provider_allowlist", + resourceId: key, + before: mapEntry(existing), + after: updated, + metadata: { + reason: input.reason ?? null, + }, + }); + + return updated; + } + + const [row] = await this.db("provider_allowlist") + .insert({ + provider_key: key, + display_name: input.displayName ?? key, + category: input.category ?? "unknown", + allowed: input.allowed, + reason: input.reason ?? null, + created_by: input.actorId, + created_at: now, + updated_by: input.actorId, + updated_at: now, + }) + .returning("*"); + + const created = mapEntry(row); + + await auditService.log({ + action: "admin.provider_allowlist_changed", + actorId: input.actorId, + actorType: input.actorType ?? "api_key", + ipAddress: input.ipAddress ?? undefined, + userAgent: input.userAgent ?? undefined, + resourceType: "provider_allowlist", + resourceId: key, + after: created, + metadata: { + reason: input.reason ?? null, + }, + }); + + return created; + } + + async deleteEntry(input: { + providerKey: string; + actorId: string; + actorType?: "user" | "api_key" | "system"; + ipAddress?: string | null; + userAgent?: string | null; + }): Promise { + const key = normalizeProviderKey(input.providerKey); + const existing = await this.db("provider_allowlist") + .where({ provider_key: key }) + .first(); + + if (!existing) return false; + + await this.db("provider_allowlist") + .where({ provider_key: key }) + .delete(); + + await auditService.log({ + action: "admin.provider_allowlist_changed", + actorId: input.actorId, + actorType: input.actorType ?? "api_key", + ipAddress: input.ipAddress ?? undefined, + userAgent: input.userAgent ?? undefined, + resourceType: "provider_allowlist", + resourceId: key, + before: mapEntry(existing), + after: null, + metadata: { + reason: "deleted", + }, + }); + + return true; + } +} + +export const providerAllowlistService = new ProviderAllowlistService(); diff --git a/backend/src/services/sources/circle.source.ts b/backend/src/services/sources/circle.source.ts index 7462a00a..4276234d 100644 --- a/backend/src/services/sources/circle.source.ts +++ b/backend/src/services/sources/circle.source.ts @@ -18,6 +18,7 @@ import { config } from "../../config/index.js"; import { withRetry } from "../../utils/retry.js"; import { PriceFetchError } from "../price.service.js"; import { schemaDriftService } from "../schemaDrift.service.js"; +import { providerAllowlistService } from "../providerAllowlist.service.js"; // --------------------------------------------------------------------------- // Circle API response shapes @@ -63,6 +64,7 @@ export interface CirclePriceResult { const SOURCE_NAME = "Circle"; const CACHE_PREFIX = "circle:price:"; const RATE_LIMIT_REDIS_KEY = "circle:rl:count"; +const PROVIDER_KEY = "circle"; /** Symbols this source can serve */ const SUPPORTED_SYMBOLS = new Set(["USDC", "EURC"]); @@ -186,6 +188,15 @@ export class CircleSource { ); } + const allowed = await providerAllowlistService.isAllowed(PROVIDER_KEY); + if (!allowed) { + throw new PriceFetchError( + "Circle source disabled by allowlist", + SOURCE_NAME, + symbol + ); + } + const cacheKey = `${CACHE_PREFIX}${upper}`; // --- cache read --- diff --git a/backend/src/services/sources/coingecko-metadata.source.ts b/backend/src/services/sources/coingecko-metadata.source.ts index 2f47d97a..f9cf9a02 100644 --- a/backend/src/services/sources/coingecko-metadata.source.ts +++ b/backend/src/services/sources/coingecko-metadata.source.ts @@ -1,8 +1,10 @@ import { logger } from "../../utils/logger.js"; +import { providerAllowlistService } from "../providerAllowlist.service.js"; import type { MetadataSourceAdapter, MetadataSourcePayload, MetadataSyncContext } from "./assetMetadataSync.types.js"; const BASE_URL = "https://api.coingecko.com/api/v3"; const TIMEOUT_MS = 8000; +const PROVIDER_KEY = "coingecko"; const SYMBOL_TO_ID: Record = { XLM: "stellar", @@ -53,6 +55,12 @@ export class CoinGeckoMetadataSource implements MetadataSourceAdapter { return null; } + const allowed = await providerAllowlistService.isAllowed(PROVIDER_KEY); + if (!allowed) { + logger.info({ providerKey: PROVIDER_KEY }, "Provider disabled by allowlist"); + return null; + } + const url = `${BASE_URL}/coins/${coinId}`; const response = await fetchWithTimeout(url); diff --git a/backend/src/services/sources/coingecko.source.ts b/backend/src/services/sources/coingecko.source.ts index c3fd04ab..943249ba 100644 --- a/backend/src/services/sources/coingecko.source.ts +++ b/backend/src/services/sources/coingecko.source.ts @@ -13,6 +13,7 @@ import { redis } from "../../utils/redis.js"; import { logger } from "../../utils/logger.js"; import { withRetry } from "../../utils/retry.js"; import { schemaDriftService } from "../schemaDrift.service.js"; +import { providerAllowlistService } from "../providerAllowlist.service.js"; // --------------------------------------------------------------------------- // Constants @@ -23,6 +24,7 @@ const BASE_URL = "https://api.coingecko.com/api/v3"; const CACHE_PREFIX = "coingecko:price:"; const CACHE_TTL_SEC = 60; const TIMEOUT_MS = 8_000; +const PROVIDER_KEY = "coingecko"; /** Map from Bridge-Watch asset symbol → CoinGecko coin ID */ const SYMBOL_TO_ID: Record = { @@ -113,6 +115,12 @@ export class CoinGeckoSource { /** Fetch price data for one or more symbols. Results are Redis-cached. */ async getPrices(symbols: string[]): Promise { + const allowed = await providerAllowlistService.isAllowed(PROVIDER_KEY); + if (!allowed) { + logger.info({ providerKey: PROVIDER_KEY }, "Provider disabled by allowlist"); + return []; + } + const upper = symbols.map((s) => s.toUpperCase()); const ids = upper.map((s) => SYMBOL_TO_ID[s]).filter(Boolean); diff --git a/backend/src/services/sources/coinmarketcap.source.ts b/backend/src/services/sources/coinmarketcap.source.ts index 58f05a01..6a09881a 100644 --- a/backend/src/services/sources/coinmarketcap.source.ts +++ b/backend/src/services/sources/coinmarketcap.source.ts @@ -10,6 +10,7 @@ import { redis } from "../../utils/redis.js"; import { logger } from "../../utils/logger.js"; import { withRetry } from "../../utils/retry.js"; +import { providerAllowlistService } from "../providerAllowlist.service.js"; // --------------------------------------------------------------------------- // Constants @@ -20,6 +21,7 @@ const BASE_URL = "https://pro-api.coinmarketcap.com/v1"; const CACHE_PREFIX = "cmc:price:"; const CACHE_TTL_SEC = 60; const TIMEOUT_MS = 8_000; +const PROVIDER_KEY = "coinmarketcap"; /** Bridge-Watch symbols CoinMarketCap can serve (all symbols must match CMC) */ const SUPPORTED_SYMBOLS = new Set([ @@ -122,6 +124,12 @@ export class CoinMarketCapSource { } async getPrices(symbols: string[]): Promise { + const allowed = await providerAllowlistService.isAllowed(PROVIDER_KEY); + if (!allowed) { + logger.info({ providerKey: PROVIDER_KEY }, "Provider disabled by allowlist"); + return []; + } + if (!this.apiKey) { logger.warn("CoinMarketCap API key not configured — skipping source"); return []; diff --git a/backend/src/services/sources/dex.source.ts b/backend/src/services/sources/dex.source.ts index e7562352..492de07e 100644 --- a/backend/src/services/sources/dex.source.ts +++ b/backend/src/services/sources/dex.source.ts @@ -13,6 +13,7 @@ import { redis } from "../../utils/redis.js"; import { logger } from "../../utils/logger.js"; import { withRetry } from "../../utils/retry.js"; +import { providerAllowlistService } from "../providerAllowlist.service.js"; // --------------------------------------------------------------------------- // Constants @@ -23,6 +24,10 @@ const CACHE_PREFIX = "dex:price:"; const CACHE_TTL_SEC = 30; const TIMEOUT_MS = 6_000; +const PROVIDER_STELLAR_HORIZON = "stellar-horizon"; +const PROVIDER_JUPITER = "jupiter"; +const PROVIDER_ONEINCH = "1inch"; + const STELLAR_HORIZON = "https://horizon.stellar.org"; const JUPITER_PRICE_URL = "https://price.jup.ag/v6/price"; const ONEINCH_PRICE_URL = "https://api.1inch.dev/price/v1.1/1"; // Ethereum mainnet @@ -147,10 +152,32 @@ export class DexSource { logger.warn({ err }, "DEX cache read error"); } + const [allowStellar, allowJupiter, allowOneInch] = await Promise.all([ + providerAllowlistService.isAllowed(PROVIDER_STELLAR_HORIZON), + providerAllowlistService.isAllowed(PROVIDER_JUPITER), + providerAllowlistService.isAllowed(PROVIDER_ONEINCH), + ]); + + if (!allowStellar) { + logger.info({ providerKey: PROVIDER_STELLAR_HORIZON }, "Provider disabled by allowlist"); + } + if (!allowJupiter) { + logger.info({ providerKey: PROVIDER_JUPITER }, "Provider disabled by allowlist"); + } + if (!allowOneInch) { + logger.info({ providerKey: PROVIDER_ONEINCH }, "Provider disabled by allowlist"); + } + const [stellarPrices, jupiterPrices, oneinchPrices] = await Promise.allSettled([ - this.fetchStellarPrices(upper.filter((s) => s in STELLAR_ASSETS)), - this.fetchJupiterPrices(upper.filter((s) => s in JUPITER_MINTS)), - this.fetchOneInchPrices(upper.filter((s) => s in ONEINCH_CONTRACTS)), + allowStellar + ? this.fetchStellarPrices(upper.filter((s) => s in STELLAR_ASSETS)) + : Promise.resolve([]), + allowJupiter + ? this.fetchJupiterPrices(upper.filter((s) => s in JUPITER_MINTS)) + : Promise.resolve([]), + allowOneInch + ? this.fetchOneInchPrices(upper.filter((s) => s in ONEINCH_CONTRACTS)) + : Promise.resolve([]), ]); const results: DexPriceResult[] = [ diff --git a/backend/src/services/sources/stellar-expert-metadata.source.ts b/backend/src/services/sources/stellar-expert-metadata.source.ts index a8e03f50..9f52061b 100644 --- a/backend/src/services/sources/stellar-expert-metadata.source.ts +++ b/backend/src/services/sources/stellar-expert-metadata.source.ts @@ -1,7 +1,10 @@ +import { logger } from "../../utils/logger.js"; +import { providerAllowlistService } from "../providerAllowlist.service.js"; import type { MetadataSourceAdapter, MetadataSourcePayload, MetadataSyncContext } from "./assetMetadataSync.types.js"; const STELLAR_EXPERT_BASE_URL = "https://api.stellar.expert/explorer/public/asset"; const TIMEOUT_MS = 8000; +const PROVIDER_KEY = "stellar-expert"; interface StellarExpertAssetResponse { _embedded?: { @@ -39,6 +42,12 @@ export class StellarExpertMetadataSource implements MetadataSourceAdapter { return null; } + const allowed = await providerAllowlistService.isAllowed(PROVIDER_KEY); + if (!allowed) { + logger.info({ providerKey: PROVIDER_KEY }, "Provider disabled by allowlist"); + return null; + } + const url = `${STELLAR_EXPERT_BASE_URL}?asset=${encodeURIComponent(symbol)}`; const response = await fetchWithTimeout(url); diff --git a/docs/asset-metadata-sync.md b/docs/asset-metadata-sync.md index 9bfdf3a1..16651e5f 100644 --- a/docs/asset-metadata-sync.md +++ b/docs/asset-metadata-sync.md @@ -20,6 +20,25 @@ Conflict resolution is deterministic: - If multiple sources provide different values for the same field, the highest-priority source wins. - The conflicting field names are recorded in `asset_metadata_sync_runs.conflicts`. +## Provider Allowlist + +Asset metadata sync respects the provider allowlist. When at least one allowlist +entry exists, only providers explicitly allowed are used for external metadata +fetches. The built-in static registry remains available regardless of allowlist +state. + +Provider keys for metadata sources: + +- `coingecko` +- `stellar-expert` + +Allowlist endpoints: + +- `GET /api/v1/providers/allowlist` +- `GET /api/v1/providers/allowlist/:providerKey` +- `PUT /api/v1/admin/providers/allowlist/:providerKey` +- `DELETE /api/v1/admin/providers/allowlist/:providerKey` + ## Supported Sync Fields The selective refresh engine supports: