Skip to content
Open
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
6 changes: 4 additions & 2 deletions modules/builder.go
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ package modules

import (
fiftyonedegreesDevicedetection "github.com/prebid/prebid-server/v4/modules/fiftyonedegrees/devicedetection"
prebidDoohcreativeapproval "github.com/prebid/prebid-server/v4/modules/prebid/doohcreativeapproval"
prebidOrtb2blocking "github.com/prebid/prebid-server/v4/modules/prebid/ortb2blocking"
prebidRulesengine "github.com/prebid/prebid-server/v4/modules/prebid/rulesengine"
wurflDevicedetection "github.com/prebid/prebid-server/v4/modules/scientiamobile/wurfl_devicedetection"
Expand All @@ -16,8 +17,9 @@ func builders() ModuleBuilders {
"devicedetection": fiftyonedegreesDevicedetection.Builder,
},
"prebid": {
"ortb2blocking": prebidOrtb2blocking.Builder,
"rulesengine": prebidRulesengine.Builder,
"doohcreativeapproval": prebidDoohcreativeapproval.Builder,
"ortb2blocking": prebidOrtb2blocking.Builder,
"rulesengine": prebidRulesengine.Builder,
},
"scientiamobile": {
"wurfl_devicedetection": wurflDevicedetection.Builder,
Expand Down
81 changes: 81 additions & 0 deletions modules/prebid/doohcreativeapproval/API_CONTRACT.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,81 @@
# DOOH Creative Approval API Contract

`prebid.doohcreativeapproval` schedules one background bulk POST for uncached or due-for-refresh non-exempt creatives observed in a DOOH auction. The endpoint returns each creative's approval status for later auctions.

The auction does not wait for this API. A first-seen creative is suppressed as `pending`, and existing creatives keep using their cached status while a refresh runs. The endpoint should be idempotent and safe to call repeatedly.

## Matching

`creative_approval_id` is the only response matching key. It is generated by PBS from PBS account ID, bidder, and `bid.crid`:

```text
creative_approval_id = "v1:" + sha256(account_id + "\x1f" + bidder + "\x1f" + bid.crid)
```

`account_id` is the PBS account/config scope, not necessarily the OpenRTB `publisher.id`. If PBS runs this module without an account, `account_id` is empty.

## Request

```json
{
"account_id": "acct",
"creatives": [
{
"creative_approval_id": "v1:...",
"bidder": "appnexus",
"creative_id": "cr-123",
"ad_id": "ad-1",
"campaign_id": "camp-1",
"advertiser_domains": ["example.com"],
"categories": ["IAB1"],
"cat_tax": 6,
"media_type": "video",
"width": 1920,
"height": 1080,
"duration": 15,
"deal_id": "deal-1",
"iurl": "https://example.com/preview.jpg"
}
]
}
```

The request may contain one or more creatives. Fields other than `creative_approval_id`, `bidder`, and `creative_id` are review metadata from the bid response. They are included to help the publisher approval service make or display a decision, but PBS matches the response only by `creative_approval_id`.

## Response

```json
{
"creatives": [
{
"creative_approval_id": "v1:...",
"status": "approved"
}
]
}
```

Allowed statuses:

- `approved`: later auctions allow the bid and the status is refreshed after `approved_ttl_seconds`.
- `rejected`: later auctions remove the bid and the status is refreshed after `rejected_ttl_seconds`.
- `pending`: later auctions remove the bid and the status is refreshed after `pending_ttl_seconds`.

Duplicate entries invalidate the returned status for that creative. Missing entries, unknown statuses, duplicate entries, endpoint errors, timeouts, and malformed responses leave an existing cached status unchanged. A creative without a prior usable status remains `pending`.

## Cache Semantics

PBS caches approval statuses in process only. The approval endpoint remains the durable source of truth.

The `*_ttl_seconds` settings control status freshness, not cache retention. When a cached status is due for refresh, PBS continues using it and calls the approval endpoint in the background. An unusable response keeps that status and schedules another attempt after `pending_ttl_seconds`.

`cache_size_bytes` limits cache memory use and must be at least 524288 bytes. Entries may be evicted when the cache reaches capacity. Eviction is handled like a missing status: the next matching bid is suppressed and schedules a background lookup.

Refreshes for the same creative are coalesced within one PBS process. `max_concurrent_lookups` limits concurrent bulk requests; when all slots are busy, PBS retains the current status and a later matching auction can start the refresh.

## Limitations

- PBS does not expose a cache inspection or cache invalidation API for this module.
- Approval changes are observed after a background refresh completes, when a cached status is due for refresh, is evicted, or is missing.
- v1 does not inspect ad markup or media content when generating `creative_approval_id`; it relies on `bid.crid` being stable for the creative approval unit.
- Cache state and refresh coordination are per PBS process, not shared across a cluster.
111 changes: 111 additions & 0 deletions modules/prebid/doohcreativeapproval/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,111 @@
# DOOH Creative Approval

`prebid.doohcreativeapproval` lets a publisher approve DOOH creatives before they can compete in an auction. The module runs only for DOOH requests. Once the module is active, a non-exempt bid is allowed through only when its last-known creative approval status is `approved`.

PBS is not the durable approval system. Each PBS process caches statuses locally and refreshes them in the background. The publisher approval service remains the source of truth.

## Terms And Scope

In this module, `account` means the PBS account/config scope. Account config controls the approval endpoint, status refresh TTLs, and exempt bidders. `publisher` means the business system or screen owner that reviews creatives. These are often the same operational boundary, but PBS does not require them to be the same identifier.

Creative approval state is scoped by PBS account, bidder, and `bid.crid`:

```text
creative_approval_id = "v1:" + sha256(account_id + "\x1f" + bidder + "\x1f" + bid.crid)
```

If the module is run without a PBS account, `account_id` is empty. Prefer account-level configuration when approvals need to be separated by publisher, tenant, or business owner.

## Hook Setup

The module must run in both stages:

```yaml
hooks:
enabled: true
modules:
prebid:
doohcreativeapproval:
enabled: true
platforms:
- dooh
timeout_ms: 100
cache_size_bytes: 10485760
max_concurrent_lookups: 8
approved_ttl_seconds: 3600
rejected_ttl_seconds: 300
pending_ttl_seconds: 60
host_execution_plan:
endpoints:
/openrtb2/auction:
stages:
processed_auction_request:
groups:
- timeout: 100
hook_sequence:
- module_code: prebid.doohcreativeapproval
hook_impl_code: dooh-creative-approval
all_processed_bid_responses:
groups:
- timeout: 100
hook_sequence:
- module_code: prebid.doohcreativeapproval
hook_impl_code: dooh-creative-approval
```

The processed-auction hook only marks eligible DOOH auctions as active. The all-processed-bid-responses hook does the filtering. If the processed stage is omitted, the module intentionally does nothing at the filtering stage.

## Account Config

Publisher-specific endpoint config should live in account config:

```json
{
"hooks": {
"modules": {
"prebid": {
"doohcreativeapproval": {
"endpoint": "https://publisher.example.com/creative-approval",
"headers": {
"Authorization": "Bearer token"
},
"exempt_bidders": ["house"]
}
}
}
}
}
```

Account config can override `enabled`, `platforms`, `endpoint`, `headers`, `timeout_ms`, status refresh TTLs, and `exempt_bidders`. `cache_size_bytes` and `max_concurrent_lookups` are host-level because the cache and refresh limit are shared by the module instance.

`timeout_ms` bounds each background HTTP request. It does not extend the auction or hook execution timeout.

## Behavior

- Exempt bidders bypass approval and do not call the publisher endpoint.
- A first-seen creative is treated as `pending` and removed from the current auction. PBS starts a background lookup for later auctions.
- Cached `approved` creatives pass. Cached `rejected` and `pending` creatives are removed.
- When a cached status is due for refresh, PBS keeps using that status while refreshing it in the background.
- Endpoint errors, timeouts, malformed responses, missing entries, unknown statuses, and duplicate entries do not replace an existing status. PBS retries after `pending_ttl_seconds`.
- If no prior status exists and the endpoint cannot return a usable status, the creative remains `pending`.
- Refreshes for the same creative are coalesced. At most `max_concurrent_lookups` bulk requests run in one PBS process.

## Cache Behavior

The `*_ttl_seconds` settings control when a cached status is due for refresh. They do not delete the last-known status. Refreshes happen outside the auction path, and an unusable refresh leaves the current status unchanged.

`cache_size_bytes` is a memory cap, not a guarantee that every cached creative remains resident. It must be at least 524288 bytes. If the cache reaches capacity, entries can be evicted. An evicted entry is treated as unknown, so its next bid is suppressed while PBS refreshes it.

PBS does not expose an admin or inspection API for this cache. The approval endpoint should keep the durable approval records.

## Limitations

- v1 supports only `platforms: ["dooh"]`. Site and app requests are intentionally ignored.
- v1 assumes `account_id + bidder + bid.crid` identifies the creative approval unit. It does not hash ad markup, media files, or preview URLs.
- Approval changes are picked up through background refreshes, cache misses, or cache eviction, not through a push channel into PBS.
- Cache contents and refresh work are local to each PBS process. Multiple PBS instances can refresh the same creative independently.
- A missing endpoint leaves the module inactive for that account. Invalid account config or a PBS hook execution failure can prevent filtering; these are configuration or host-execution failures rather than approval lookup results.
- The first auction for an uncached creative is always suppressed, even if the publisher endpoint would immediately approve it.

See `API_CONTRACT.md` for the external approval API.
88 changes: 88 additions & 0 deletions modules/prebid/doohcreativeapproval/cache.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,88 @@
package doohcreativeapproval

import (
"encoding/json"
"fmt"
"time"

"github.com/coocood/freecache"
)

type cachedApprovalStatus struct {
CreativeApprovalID string `json:"creative_approval_id"`
Status approvalStatus `json:"status"`
RefreshAfterUnixNano int64 `json:"refresh_after_unix_nano"`
}

type cachedApprovalLookup struct {
Status approvalStatus
RefreshDue bool
}

type approvalCache struct {
cache *freecache.Cache
marshal func(v any) ([]byte, error)
unmarshal func(data []byte, v any) error
now func() time.Time
}

func newApprovalCache(sizeBytes int) *approvalCache {
return &approvalCache{
cache: freecache.NewCache(sizeBytes),
marshal: json.Marshal,
unmarshal: json.Unmarshal,
now: time.Now,
}
}

func (c *approvalCache) get(creativeApprovalID string) (cachedApprovalLookup, bool) {
if c == nil || c.cache == nil || creativeApprovalID == "" {
return cachedApprovalLookup{}, false
}

data, err := c.cache.Get([]byte(creativeApprovalID))
if err != nil {
return cachedApprovalLookup{}, false
}

var entry cachedApprovalStatus
if err := c.unmarshal(data, &entry); err != nil {
return cachedApprovalLookup{}, false
}
if entry.CreativeApprovalID != creativeApprovalID || !isValidApprovalStatus(entry.Status) || entry.RefreshAfterUnixNano <= 0 {
return cachedApprovalLookup{}, false
}

return cachedApprovalLookup{
Status: entry.Status,
RefreshDue: !c.currentTime().Before(time.Unix(0, entry.RefreshAfterUnixNano)),
}, true
}

func (c *approvalCache) set(creativeApprovalID string, status approvalStatus, refreshSeconds int) error {
if c == nil || c.cache == nil || creativeApprovalID == "" || refreshSeconds <= 0 || !isValidApprovalStatus(status) {
return nil
}

entry := cachedApprovalStatus{
CreativeApprovalID: creativeApprovalID,
Status: status,
RefreshAfterUnixNano: c.currentTime().Add(time.Duration(refreshSeconds) * time.Second).UnixNano(),
}
data, err := c.marshal(entry)
if err != nil {
return fmt.Errorf("marshal approval cache entry: %s", err)
}

if err := c.cache.Set([]byte(creativeApprovalID), data, 0); err != nil {
return fmt.Errorf("store approval cache entry: %s", err)
}
return nil
}

func (c *approvalCache) currentTime() time.Time {
if c.now == nil {
return time.Now()
}
return c.now()
}
77 changes: 77 additions & 0 deletions modules/prebid/doohcreativeapproval/cache_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,77 @@
package doohcreativeapproval

import (
"errors"
"testing"
"time"

"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)

func TestApprovalCacheGetSet(t *testing.T) {
cache := newApprovalCache(1024 * 1024)

cache.set("v1:approved", approvalStatusApproved, 60)
cache.set("v1:rejected", approvalStatusRejected, 60)
cache.set("v1:pending", approvalStatusPending, 60)

lookup, ok := cache.get("v1:approved")
assert.True(t, ok)
assert.Equal(t, approvalStatusApproved, lookup.Status)
assert.False(t, lookup.RefreshDue)

lookup, ok = cache.get("v1:rejected")
assert.True(t, ok)
assert.Equal(t, approvalStatusRejected, lookup.Status)
assert.False(t, lookup.RefreshDue)

lookup, ok = cache.get("v1:pending")
assert.True(t, ok)
assert.Equal(t, approvalStatusPending, lookup.Status)
assert.False(t, lookup.RefreshDue)
}

func TestApprovalCacheMisses(t *testing.T) {
cache := newApprovalCache(1024 * 1024)

cache.set("v1:zero-ttl", approvalStatusApproved, 0)
cache.set("v1:bad-status", "unknown", 60)

_, ok := cache.get("v1:missing")
assert.False(t, ok)

_, ok = cache.get("v1:zero-ttl")
assert.False(t, ok)

_, ok = cache.get("v1:bad-status")
assert.False(t, ok)
}

func TestApprovalCacheRefreshDueKeepsLastStatus(t *testing.T) {
cache := newApprovalCache(1024 * 1024)
now := time.Unix(1000, 0)
cache.now = func() time.Time {
return now
}

cache.set("v1:refresh", approvalStatusApproved, 1)
now = now.Add(2 * time.Second)

lookup, ok := cache.get("v1:refresh")
assert.True(t, ok)
assert.Equal(t, approvalStatusApproved, lookup.Status)
assert.True(t, lookup.RefreshDue)
}

func TestApprovalCacheSetReturnsWriteError(t *testing.T) {
cache := newApprovalCache(1024 * 1024)
cache.marshal = func(v any) ([]byte, error) {
return nil, errors.New("marshal failed")
}

err := cache.set("v1:write-error", approvalStatusApproved, 60)

require.Error(t, err)
assert.Contains(t, err.Error(), "marshal approval cache entry")
}
Loading
Loading