From c8d802d78fa1650a6f2bc2bce9da78dd62b0799c Mon Sep 17 00:00:00 2001 From: Yun Chan Date: Wed, 16 Sep 2026 23:23:39 +0900 Subject: [PATCH] feat(desktop): fill sponsor slots from direct house campaigns When mediation had no programmatic fill, the banner and rewarded surfaces collapsed to empty space. Direct house sponsors now serve their own copy and click-through, with the same settlement accounting used by the mediated network, and the mediation engine tests cover the added path. --- .../main/services/ads/AdMediationEngine.ts | 6 + .../services/ads/DirectHouseSponsorAdapter.ts | 225 +++++++++++++++++- .../src/renderer/components/ads/AdBanner.tsx | 48 ++-- .../components/ads/RewardedQuotaModal.tsx | 52 ++-- .../tests/unit/AdMediationEngine.spec.ts | 93 +++++++- 5 files changed, 363 insertions(+), 61 deletions(-) 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()