diff --git a/apps/desktop/src/main/services/ads/AdMediationEngine.ts b/apps/desktop/src/main/services/ads/AdMediationEngine.ts index 43af4cc..541741c 100644 --- a/apps/desktop/src/main/services/ads/AdMediationEngine.ts +++ b/apps/desktop/src/main/services/ads/AdMediationEngine.ts @@ -102,6 +102,12 @@ export class AdMediationEngine { networks: newConfig.networks?.map((network) => ({ ...network })) ?? this.config.networks.map((network) => ({ ...network })), } + for (const network of this.config.networks) { + const adapter = this.adapters.get(network.id) + if (adapter) { + void adapter.init(network as unknown as Record) + } + } return this.getConfig() } diff --git a/apps/desktop/src/main/services/ads/DirectHouseSponsorAdapter.ts b/apps/desktop/src/main/services/ads/DirectHouseSponsorAdapter.ts index af7310c..03f0267 100644 --- a/apps/desktop/src/main/services/ads/DirectHouseSponsorAdapter.ts +++ b/apps/desktop/src/main/services/ads/DirectHouseSponsorAdapter.ts @@ -1,12 +1,219 @@ -import { UnavailableAdAdapter } from './UnavailableAdAdapter' +// apps/desktop/src/main/services/ads/DirectHouseSponsorAdapter.ts +// Real, configurable direct/house sponsor adapter. +// +// Unlike the placeholder networks, this adapter performs an actual mediated +// bid against an operator-configured HTTPS decision endpoint and reports +// impressions, clicks, and reward completions back to it. It stays fail-closed: +// with no endpoint configured it never fabricates a bid or creative, and it +// validates every returned creative before exposing it. -export class DirectHouseSponsorAdapter extends UnavailableAdAdapter { - constructor() { - super( - 'direct_sponsor', - 'Direct House Sponsor', - ['banner_dock', 'rewarded_video', 'export_sponsor'], - 12, - ) +import type { + AdNetworkId, + AdFormat, + AdCreativePayload, + AdMediationAuctionRequest, +} from '@d3ro/core/types' +import type { AdBidResponse, IAdNetworkAdapter } from './BaseAdAdapter' + +interface DirectSponsorConfig { + endpointUrl?: string + apiSecret?: string + adUnitId?: string +} + +const REQUEST_TIMEOUT_MS = 1500 +const MAX_BID_RESPONSE_BYTES = 64 * 1024 + +function trimTrailingSlash(value: string): string { + return value.replace(/\/+$/, '') +} + +function isHttpsUrl(value: unknown): value is string { + if (typeof value !== 'string') return false + try { + return new URL(value).protocol === 'https:' + } catch { + return false + } +} + +function isHttpUrl(value: unknown): value is string { + if (typeof value !== 'string') return false + try { + const protocol = new URL(value).protocol + return protocol === 'https:' || protocol === 'http:' + } catch { + return false + } +} + +export class DirectHouseSponsorAdapter implements IAdNetworkAdapter { + readonly networkId: AdNetworkId | string = 'direct_sponsor' + readonly networkName = 'Direct House Sponsor' + readonly supportedFormats: AdFormat[] = ['banner_dock', 'rewarded_video', 'export_sponsor'] + readonly defaultFloorEcpm = 12 + + private config: DirectSponsorConfig = {} + + async init(config?: Record): Promise { + this.config = { + endpointUrl: + typeof config?.endpointUrl === 'string' && isHttpUrl(config.endpointUrl) + ? config.endpointUrl + : undefined, + apiSecret: typeof config?.apiSecret === 'string' ? config.apiSecret : undefined, + adUnitId: typeof config?.adUnitId === 'string' ? config.adUnitId : undefined, + } + } + + private async post(path: string, payload: Record): Promise { + const endpoint = this.config.endpointUrl + if (!endpoint) throw new Error('adapter_not_configured') + const controller = new AbortController() + const timer = setTimeout(() => controller.abort(), REQUEST_TIMEOUT_MS) + const headers: Record = { 'Content-Type': 'application/json' } + if (this.config.apiSecret) headers.Authorization = `Bearer ${this.config.apiSecret}` + try { + return await fetch(`${trimTrailingSlash(endpoint)}${path}`, { + method: 'POST', + headers, + body: JSON.stringify(payload), + signal: controller.signal, + }) + } finally { + clearTimeout(timer) + } + } + + async requestBid(request: AdMediationAuctionRequest): Promise { + const start = Date.now() + if (!this.config.endpointUrl) { + return { hasBid: false, bidEcpm: 0, latencyMs: 0, error: 'adapter_not_configured' } + } + try { + const response = await this.post('/bid', { + networkId: this.networkId, + placement: request.placement, + format: request.format, + floorEcpm: request.floorEcpm ?? this.defaultFloorEcpm, + adUnitId: this.config.adUnitId ?? null, + }) + if (!response.ok) { + return { + hasBid: false, + bidEcpm: 0, + latencyMs: Date.now() - start, + error: `http_${response.status}`, + } + } + const text = await response.text() + if (text.length > MAX_BID_RESPONSE_BYTES) { + return { + hasBid: false, + bidEcpm: 0, + latencyMs: Date.now() - start, + error: 'response_too_large', + } + } + const payload: unknown = JSON.parse(text) + const creative = this.parseCreative(payload, request) + if (!creative) { + return { hasBid: false, bidEcpm: 0, latencyMs: Date.now() - start, error: 'invalid_creative' } + } + return { hasBid: true, bidEcpm: creative.bidEcpm, creative, latencyMs: Date.now() - start } + } catch (error) { + const message = error instanceof Error ? error.message : 'bid_failed' + return { hasBid: false, bidEcpm: 0, latencyMs: Date.now() - start, error: message } + } + } + + async reportImpression(adId: string): Promise { + if (!this.config.endpointUrl) return + try { + await this.post('/impression', { adId }) + } catch { + return + } + } + + async reportClick(adId: string): Promise { + if (!this.config.endpointUrl) return + try { + await this.post('/click', { adId }) + } catch { + return + } + } + + async reportRewardCompletion(adId: string): Promise<{ success: boolean; tokenReward: number }> { + if (!this.config.endpointUrl) return { success: false, tokenReward: 0 } + try { + const response = await this.post('/reward', { adId }) + if (!response.ok) return { success: false, tokenReward: 0 } + const payload = (await response.json()) as { success?: unknown; tokenReward?: unknown } + const tokenReward = + typeof payload.tokenReward === 'number' && Number.isFinite(payload.tokenReward) + ? Math.max(0, Math.floor(payload.tokenReward)) + : 0 + const success = payload.success === true && tokenReward > 0 + return { success, tokenReward: success ? tokenReward : 0 } + } catch { + return { success: false, tokenReward: 0 } + } + } + + private parseCreative( + payload: unknown, + request: AdMediationAuctionRequest, + ): AdCreativePayload | null { + if (typeof payload !== 'object' || payload === null) return null + const record = payload as Record + const source = ( + typeof record.creative === 'object' && record.creative !== null + ? record.creative + : record + ) as Record + + const bidEcpm = + typeof record.bidEcpm === 'number' + ? record.bidEcpm + : typeof source.bidEcpm === 'number' + ? source.bidEcpm + : Number.NaN + const id = typeof source.id === 'string' ? source.id : '' + const title = typeof source.title === 'string' ? source.title : '' + const advertiserName = typeof source.advertiserName === 'string' ? source.advertiserName : '' + const clickUrl = source.clickUrl + + if ( + !id.trim() || + !title.trim() || + !advertiserName.trim() || + !Number.isFinite(bidEcpm) || + bidEcpm <= 0 || + !isHttpsUrl(clickUrl) + ) { + return null + } + + return { + id, + networkId: this.networkId, + networkName: this.networkName, + title, + description: typeof source.description === 'string' ? source.description : '', + ctaText: typeof source.ctaText === 'string' ? source.ctaText : 'Learn more', + iconUrl: typeof source.iconUrl === 'string' ? source.iconUrl : undefined, + bannerUrl: typeof source.bannerUrl === 'string' ? source.bannerUrl : undefined, + videoUrl: typeof source.videoUrl === 'string' ? source.videoUrl : undefined, + clickUrl, + sponsorTag: typeof source.sponsorTag === 'string' ? source.sponsorTag : 'Sponsored', + advertiserName, + bidEcpm, + format: request.format, + rewardTokens: typeof source.rewardTokens === 'number' ? source.rewardTokens : undefined, + durationSeconds: + typeof source.durationSeconds === 'number' ? source.durationSeconds : undefined, + } } } diff --git a/apps/desktop/src/renderer/components/ads/AdBanner.tsx b/apps/desktop/src/renderer/components/ads/AdBanner.tsx index 3a56b6b..9d1a6c3 100644 --- a/apps/desktop/src/renderer/components/ads/AdBanner.tsx +++ b/apps/desktop/src/renderer/components/ads/AdBanner.tsx @@ -68,7 +68,7 @@ export function AdBanner({ tier, onOpenUpgradeModal }: AdBannerProps): React.Rea px: 2, py: 1.25, borderRadius: d3roRadius.inner, - bgcolor: 'rgba(17, 26, 48, 0.85)', + bgcolor: 'var(--d3-bg-card-soft)', backdropFilter: 'blur(16px)', border: `1px solid ${d3roPalette.glass.hairline}`, boxShadow: d3roShadow.card, @@ -91,19 +91,19 @@ export function AdBanner({ tier, onOpenUpgradeModal }: AdBannerProps): React.Rea borderRadius: '8px', bgcolor: creative.networkId === 'google_ad_manager' - ? 'rgba(234, 179, 8, 0.15)' + ? 'var(--d3-status-warning)' : creative.networkId === 'ethical_ads' - ? 'rgba(34, 197, 94, 0.15)' + ? 'var(--d3-status-success)' : creative.networkId === 'carbon_ads' - ? 'rgba(249, 115, 22, 0.15)' - : 'rgba(59, 130, 246, 0.15)', + ? 'var(--d3-status-warning)' + : 'var(--d3-accent-glow)', border: `1px solid ${ creative.networkId === 'google_ad_manager' - ? 'rgba(234, 179, 8, 0.4)' + ? 'var(--d3-status-warning)' : creative.networkId === 'ethical_ads' - ? 'rgba(34, 197, 94, 0.4)' + ? 'var(--d3-status-success)' : creative.networkId === 'carbon_ads' - ? 'rgba(249, 115, 22, 0.4)' + ? 'var(--d3-status-warning)' : d3roPalette.accent.dim }`, display: 'flex', @@ -115,11 +115,11 @@ export function AdBanner({ tier, onOpenUpgradeModal }: AdBannerProps): React.Rea fontFamily: d3roFontMono, color: creative.networkId === 'google_ad_manager' - ? '#facc15' + ? 'var(--d3-status-warning)' : creative.networkId === 'ethical_ads' - ? '#4ade80' + ? 'var(--d3-status-success)' : creative.networkId === 'carbon_ads' - ? '#fb923c' + ? 'var(--d3-status-warning)' : d3roPalette.accent.light, }} > @@ -147,8 +147,8 @@ export function AdBanner({ tier, onOpenUpgradeModal }: AdBannerProps): React.Rea fontFamily: d3roFontMono, fontSize: '9px', fontWeight: 600, - color: '#000', - bgcolor: '#facc15', + color: 'var(--d3-bg-app)', + bgcolor: 'var(--d3-status-warning)', px: 0.6, py: 0.1, borderRadius: '3px', @@ -178,22 +178,22 @@ export function AdBanner({ tier, onOpenUpgradeModal }: AdBannerProps): React.Rea fontWeight: 500, color: creative.networkId === 'ethical_ads' - ? '#4ade80' + ? 'var(--d3-status-success)' : creative.networkId === 'carbon_ads' - ? '#fb923c' + ? 'var(--d3-status-warning)' : d3roPalette.accent.light, bgcolor: creative.networkId === 'ethical_ads' - ? 'rgba(34, 197, 94, 0.12)' + ? 'var(--d3-status-success)' : creative.networkId === 'carbon_ads' - ? 'rgba(249, 115, 22, 0.12)' - : 'rgba(59, 130, 246, 0.15)', + ? 'var(--d3-status-warning)' + : 'var(--d3-accent-glow)', border: `1px solid ${ creative.networkId === 'ethical_ads' - ? 'rgba(34, 197, 94, 0.3)' + ? 'var(--d3-status-success)' : creative.networkId === 'carbon_ads' - ? 'rgba(249, 115, 22, 0.3)' - : 'rgba(59, 130, 246, 0.3)' + ? 'var(--d3-status-warning)' + : 'var(--d3-accent-glow)' }`, px: 0.8, py: 0.2, @@ -232,11 +232,11 @@ export function AdBanner({ tier, onOpenUpgradeModal }: AdBannerProps): React.Rea fontWeight: 600, textTransform: 'none', color: d3roPalette.accent.light, - bgcolor: 'rgba(59, 130, 246, 0.12)', + bgcolor: 'var(--d3-accent-glow)', border: `1px solid ${d3roPalette.accent.dim}`, borderRadius: '8px', '&:hover': { - bgcolor: 'rgba(59, 130, 246, 0.22)', + bgcolor: 'var(--d3-accent-glow)', }, }} > @@ -252,7 +252,7 @@ export function AdBanner({ tier, onOpenUpgradeModal }: AdBannerProps): React.Rea color: d3roPalette.text.dimLabel, cursor: 'pointer', textDecoration: 'underline', - '&:hover': { color: d3roPalette.tag.greenText }, + '&:hover': { color: d3roPalette.tag.green }, }} > Remove ads with Pro diff --git a/apps/desktop/src/renderer/components/ads/RewardedQuotaModal.tsx b/apps/desktop/src/renderer/components/ads/RewardedQuotaModal.tsx index 854d369..9f75bfe 100644 --- a/apps/desktop/src/renderer/components/ads/RewardedQuotaModal.tsx +++ b/apps/desktop/src/renderer/components/ads/RewardedQuotaModal.tsx @@ -112,10 +112,10 @@ export function RewardedQuotaModal({ left: '50%', transform: 'translate(-50%, -50%)', width: { xs: '90%', sm: 440 }, - bgcolor: d3roPalette.bg.modal, - borderRadius: d3roRadius.modal, + bgcolor: d3roPalette.bg.card, + borderRadius: d3roRadius.outer, border: `1px solid ${d3roPalette.glass.hairline}`, - boxShadow: d3roShadow.modal, + boxShadow: d3roShadow.dialog, p: 3, outline: 'none', }} @@ -144,10 +144,10 @@ export function RewardedQuotaModal({ left: '50%', transform: 'translate(-50%, -50%)', width: { xs: '90%', sm: 480 }, - bgcolor: d3roPalette.bg.modal, - borderRadius: d3roRadius.modal, + bgcolor: d3roPalette.bg.card, + borderRadius: d3roRadius.outer, border: `1px solid ${d3roPalette.glass.hairline}`, - boxShadow: d3roShadow.modal, + boxShadow: d3roShadow.dialog, p: 3, outline: 'none', }} @@ -160,7 +160,7 @@ export function RewardedQuotaModal({ width: 32, height: 32, borderRadius: '8px', - bgcolor: 'rgba(56, 189, 248, 0.15)', + bgcolor: 'var(--d3-tag-cyan)', display: 'flex', alignItems: 'center', justifyContent: 'center', @@ -182,7 +182,7 @@ export function RewardedQuotaModal({ sx={{ height: 220, borderRadius: d3roRadius.inner, - bgcolor: '#000000', + bgcolor: 'var(--d3-bg-inset)', border: `1px solid ${d3roPalette.glass.hairline}`, position: 'relative', display: 'flex', @@ -211,8 +211,8 @@ export function RewardedQuotaModal({ fontFamily: d3roFontMono, fontSize: '9px', fontWeight: 600, - color: '#fff', - bgcolor: '#2563eb', + color: 'var(--d3-text-inverse)', + bgcolor: 'var(--d3-accent-dark)', px: 1, py: 0.3, borderRadius: '4px', @@ -225,7 +225,7 @@ export function RewardedQuotaModal({ sx={{ fontFamily: d3roFontMono, fontSize: '9px', - color: 'rgba(255, 255, 255, 0.6)', + color: 'var(--d3-overlay-strong)', }} > Reward verification required @@ -240,8 +240,8 @@ export function RewardedQuotaModal({ display: 'flex', alignItems: 'center', gap: 0.5, - color: '#fff', - bgcolor: 'rgba(0, 0, 0, 0.6)', + color: 'var(--d3-text-inverse)', + bgcolor: 'var(--d3-scrim)', px: 1, py: 0.3, borderRadius: '4px', @@ -261,22 +261,22 @@ export function RewardedQuotaModal({ width: 56, height: 56, borderRadius: '14px', - bgcolor: 'rgba(37, 99, 235, 0.25)', - border: `1px solid rgba(59, 130, 246, 0.5)`, + bgcolor: 'var(--d3-accent-glow)', + border: `1px solid var(--d3-accent-glow)`, display: 'flex', alignItems: 'center', justifyContent: 'center', mb: 1.5, - boxShadow: '0 0 20px rgba(37, 99, 235, 0.4)', + boxShadow: '0 0 20px var(--d3-accent-glow)', }} > - + - + {creative.title} - + {creative.description} @@ -287,9 +287,9 @@ export function RewardedQuotaModal({ value={progressPercent} sx={{ height: 5, - bgcolor: 'rgba(255, 255, 255, 0.1)', + bgcolor: 'var(--d3-overlay-strong)', '& .MuiLinearProgress-bar': { - bgcolor: completed ? '#22c55e' : '#3b82f6', + bgcolor: completed ? 'var(--d3-status-success)' : 'var(--d3-accent-main)', }, }} /> @@ -312,12 +312,12 @@ export function RewardedQuotaModal({ fontWeight: 500, fontSize: '12px', textTransform: 'none', - bgcolor: d3roPalette.tag.greenText, - color: '#000', + bgcolor: d3roPalette.tag.green, + color: 'var(--d3-bg-app)', px: 2.5, py: 0.8, borderRadius: '8px', - '&:hover': { bgcolor: '#4ade80' }, + '&:hover': { bgcolor: 'var(--d3-status-success)' }, }} > Verify reward @@ -329,8 +329,8 @@ export function RewardedQuotaModal({ fontFamily: d3roFontSans, fontSize: '12px', textTransform: 'none', - color: 'rgba(255, 255, 255, 0.3)', - bgcolor: 'rgba(255, 255, 255, 0.05)', + color: 'var(--d3-overlay-strong)', + bgcolor: 'var(--d3-overlay-strong)', px: 2, py: 0.8, borderRadius: '8px', diff --git a/apps/desktop/tests/unit/AdMediationEngine.spec.ts b/apps/desktop/tests/unit/AdMediationEngine.spec.ts index 432a440..531e8cd 100644 --- a/apps/desktop/tests/unit/AdMediationEngine.spec.ts +++ b/apps/desktop/tests/unit/AdMediationEngine.spec.ts @@ -1,4 +1,4 @@ -import { beforeEach, describe, expect, it } from 'vitest' +import { beforeEach, describe, expect, it, vi } from 'vitest' import type { AdMediationConfig } from '@d3ro/core/types' import { getAdMediationEngine } from '../../src/main/services/ads/AdMediationEngine' import { getAdSettlementService } from '../../src/main/services/ads/AdSettlementService' @@ -23,7 +23,6 @@ const adapterCases = [ ['InMobi', new InMobiAdapter()], ['PubMatic', new PubMaticAdapter()], ['Mintegral', new MintegralAdapter()], - ['Direct sponsor', new DirectHouseSponsorAdapter()], ] as const describe('desktop ad provider boundaries', () => { @@ -49,6 +48,96 @@ describe('desktop ad provider boundaries', () => { }) }) +describe('DirectHouseSponsorAdapter live decision endpoint', () => { + it('is fail-closed until an endpoint is configured', async () => { + const adapter = new DirectHouseSponsorAdapter() + await adapter.init() + const bid = await adapter.requestBid({ placement: 'bottom_dock_banner', format: 'banner_dock' }) + + expect(bid).toEqual({ + hasBid: false, + bidEcpm: 0, + latencyMs: 0, + error: 'adapter_not_configured', + }) + await expect(adapter.reportRewardCompletion('forged-ad-id')).resolves.toEqual({ + success: false, + tokenReward: 0, + }) + }) + + it('returns a validated live bid from a configured endpoint', async () => { + const adapter = new DirectHouseSponsorAdapter() + await adapter.init({ endpointUrl: 'https://ads.example.com', apiSecret: 'secret' }) + const fetchMock = vi.fn().mockResolvedValue( + new Response( + JSON.stringify({ + bidEcpm: 9.5, + creative: { + id: 'creative-1', + title: 'Sponsor', + advertiserName: 'Example Inc', + clickUrl: 'https://example.com/offer', + format: 'banner_dock', + }, + }), + { status: 200 }, + ), + ) + vi.stubGlobal('fetch', fetchMock) + try { + const bid = await adapter.requestBid({ + placement: 'bottom_dock_banner', + format: 'banner_dock', + floorEcpm: 2, + }) + + expect(bid.hasBid).toBe(true) + expect(bid.bidEcpm).toBe(9.5) + expect(bid.creative?.networkId).toBe('direct_sponsor') + expect(bid.creative?.clickUrl).toBe('https://example.com/offer') + const [url, init] = fetchMock.mock.calls[0] as [string, RequestInit] + expect(String(url)).toBe('https://ads.example.com/bid') + expect(init.headers).toMatchObject({ Authorization: 'Bearer secret' }) + } finally { + vi.unstubAllGlobals() + } + }) + + it('rejects a creative whose click target is not https', async () => { + const adapter = new DirectHouseSponsorAdapter() + await adapter.init({ endpointUrl: 'https://ads.example.com' }) + vi.stubGlobal( + 'fetch', + vi.fn().mockResolvedValue( + new Response( + JSON.stringify({ + bidEcpm: 9.5, + creative: { + id: 'c', + title: 't', + advertiserName: 'a', + clickUrl: 'http://insecure.example.com', + format: 'banner_dock', + }, + }), + { status: 200 }, + ), + ), + ) + try { + const bid = await adapter.requestBid({ + placement: 'bottom_dock_banner', + format: 'banner_dock', + }) + expect(bid.hasBid).toBe(false) + expect(bid.error).toBe('invalid_creative') + } finally { + vi.unstubAllGlobals() + } + }) +}) + describe('AdMediationEngine fail-closed auction and telemetry', () => { const engine = getAdMediationEngine() const settlement = getAdSettlementService()