mirror of
https://github.com/profullstack/logicsrc.git
synced 2026-08-13 22:37:29 +00:00
feat(agentad): AgentAd Marketplace PRD + reference exchange (M5)
Add the AgentBBS M5 "AgentAd marketplace" spec and a working reference
implementation built on the existing @logicsrc/schemas AgentAd contracts.
- docs/agentad-marketplace.md: two-sided exchange PRD (buy/sell sides,
match -> auction -> pace -> serve -> meter -> settle, CoinPay settlement,
AgentBBS as reference publisher, milestones M5.0-M5.5).
- packages/agentad (@logicsrc/agentad): reference exchange
- builders that emit schema-valid, always-disclosed ad/campaign/placement docs
- HMAC-signed, single-use impression/click tracking tokens
- AgentAdExchange: targeting/format/category matching, second-price auction,
budget pacing + daily caps, frequency capping, token-driven metering
- pluggable settlement (InMemorySettlement) that can't overspend escrow
- runtime validation against the canonical agentad-*.schema.json
- 22 vitest cases (builders, tokens, full serve/meter/settle lifecycle).
- Wire package into root build; link the PRD from README + docs/agentad.md.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
parent
0bca203527
commit
7d62e3b661
18 changed files with 1862 additions and 2 deletions
|
|
@ -19,6 +19,7 @@ packages/
|
|||
tui terminal UI
|
||||
schemas LogicSRC JSON schemas
|
||||
validators schema validation utilities
|
||||
agentad AgentAd Marketplace exchange (auction, metering, settlement)
|
||||
plugin-core plugin manifest and loader runtime
|
||||
plugins/
|
||||
coinpay default DID, wallet, payment, and escrow plugin
|
||||
|
|
@ -54,7 +55,7 @@ It provides read-only resources for docs and schemas, validation/example tools,
|
|||
## v1.0.0 Priorities
|
||||
|
||||
- LogicSRC task, agent, run, event, permission, and plugin schemas.
|
||||
- AgentAd: disclosed, agent-readable ad schemas for CLI/agent advertising (see `docs/agentad.md`); cl1s.tech is the reference network.
|
||||
- AgentAd: disclosed, agent-readable ad schemas for CLI/agent advertising (see `docs/agentad.md`); cl1s.tech is the reference network. The two-sided exchange on top is specified in `docs/agentad-marketplace.md`.
|
||||
- LogicSRC CLI, SDK, TUI, PWA, MCP, and curl-compatible API conventions.
|
||||
- CommandBoard.run reference implementation.
|
||||
- Monorepo-maintained plugin system.
|
||||
|
|
|
|||
224
docs/agentad-marketplace.md
Normal file
224
docs/agentad-marketplace.md
Normal file
|
|
@ -0,0 +1,224 @@
|
|||
# AgentAd Marketplace (PRD)
|
||||
|
||||
**Status:** Draft
|
||||
**Owner:** Profullstack / LogicSRC
|
||||
**Milestone:** AgentBBS **M5 — AgentAd marketplace**
|
||||
**Standard:** built on the [AgentAd](./agentad.md) primitive family in `@logicsrc/schemas`
|
||||
|
||||
> AgentAd (see [`docs/agentad.md`](./agentad.md)) already defines the *contracts*:
|
||||
> `agentad.ad`, `agentad.placement`, `agentad.ad_request`, `agentad.ad_response`,
|
||||
> `agentad.impression`, `agentad.click`, and `agentad.campaign`. This PRD defines the
|
||||
> **marketplace** — the two-sided product layer that lets advertisers buy inventory and
|
||||
> publishers sell it, with matching, serving, metering, and settlement in between.
|
||||
> [cl1s.tech](https://github.com/profullstack/cl1s.tech) is the reference hosted network;
|
||||
> AgentBBS is the reference **publisher surface** (SSH/TUI over the CLI/TUI/agent surfaces).
|
||||
|
||||
## 1. Summary
|
||||
|
||||
The AgentAd Marketplace is a disclosed, agent-native advertising exchange for command-line
|
||||
tools and AI agents. Advertisers fund **campaigns** of **ads**; publishers register
|
||||
**placements** in their CLI/TUI/agent surfaces; the exchange matches, serves, meters, and
|
||||
settles. Every unit is disclosed as sponsored and carries a `machine_readable` payload so an
|
||||
agent can reason about the offer rather than scrape rendered text.
|
||||
|
||||
The marketplace is the product wrapper around the AgentAd contracts: it adds identity-scoped
|
||||
accounts, listings/discovery, an auction, budget pacing, verified metering, and CoinPay-backed
|
||||
billing and payouts. LogicSRC owns the schemas and the exchange contracts; hosted networks
|
||||
(cl1s.tech first) run the exchange; surfaces like AgentBBS consume it.
|
||||
|
||||
## 2. Goals & non-goals
|
||||
|
||||
### Goals
|
||||
- A working two-sided exchange: advertiser buy-side + publisher sell-side over the existing
|
||||
AgentAd schemas, with **no schema forks** — extend via the reference network, not the standard.
|
||||
- **Disclosure is non-negotiable.** Every served unit sets `disclosure.sponsored: true` and a
|
||||
visible label; agent responses expose `sponsored: true` explicitly. Undisclosed serving is a
|
||||
protocol violation and a fillable-inventory ban.
|
||||
- **DID-scoped accounts.** Advertisers and publishers are identified by DID (via CoinPay/ANS);
|
||||
campaigns and placements bind to `advertiser_did` / `publisher_did`.
|
||||
- **Verified metering.** Impressions and clicks are only billable when confirmed with the
|
||||
signed `impression_token` from the `ad_response`.
|
||||
- **CoinPay settlement.** Budgets are escrowed; publisher payouts and network fees settle through
|
||||
CoinPay; reputation events flow from spend and delivery quality.
|
||||
- **AgentBBS integration.** AgentBBS registers placements (menu banners, TUI panels, agent chat)
|
||||
and earns payouts; advertisers can target the BBS surface.
|
||||
|
||||
### Non-goals (v1)
|
||||
- Real-time bidding across external third-party exchanges (single-exchange auction only).
|
||||
- Rich media / image ads beyond `banner`/`ansi_art` and `media.icon`.
|
||||
- Human web-display advertising — this is CLI/TUI/agent/CI surfaces only.
|
||||
- Cross-network ad syndication (out of scope until the contracts stabilize on one network).
|
||||
|
||||
## 3. Personas
|
||||
|
||||
| Persona | Wants |
|
||||
| --- | --- |
|
||||
| **Advertiser** (e.g. Railway, a tool vendor) | Reach agents/CLIs with a disclosed, budgeted campaign; pay per CPM/CPC/CPA/flat; see delivery + spend. |
|
||||
| **Publisher** (AgentBBS, a CLI, an agent) | Register placements, control formats/frequency/category blocks, earn payouts, keep UX clean. |
|
||||
| **Agent consumer** | Receive a structured `sponsored: true` offer it can act on, or ignore, without being deceived. |
|
||||
| **Human consumer** | See a clearly `[Sponsored]` unit in the terminal, never mistaken for organic output. |
|
||||
| **Network operator** (cl1s.tech) | Run the auction, meter honestly, take a transparent fee, keep advertisers and publishers trusting the exchange. |
|
||||
|
||||
## 4. Marketplace model
|
||||
|
||||
Two sides bridged by the exchange:
|
||||
|
||||
```
|
||||
Advertiser ──creates──▶ Campaign ──contains──▶ Ads
|
||||
│ │ budget/schedule/targeting (agentad.campaign / agentad.ad)
|
||||
│ escrow (CoinPay) │
|
||||
▼ ▼
|
||||
┌────────────── Exchange ──────────────┐
|
||||
│ match → auction → pace → serve │
|
||||
│ meter (impression/click) → settle │
|
||||
└──────────────────────────────────────┘
|
||||
▲ ▲
|
||||
│ payout (CoinPay) │
|
||||
Publisher ──registers──▶ Placement ──requests fill──▶ ad_request → ad_response
|
||||
surface/formats/caps (agentad.placement / ad_request / ad_response)
|
||||
```
|
||||
|
||||
### 4.1 Buy side
|
||||
- **Advertiser account** keyed by `advertiser_did`; onboarding funds a balance via CoinPay.
|
||||
- **Campaign** (`agentad.campaign`): `budget.total`, `budget.daily_cap`, `currency`, `schedule`,
|
||||
`status` (`draft|active|paused|completed`), `ad_ids`.
|
||||
- **Ad** (`agentad.ad`): `format`, `title`, `body`, `url`, `cta`, mandatory `disclosure`,
|
||||
optional `machine_readable`, `targeting` (surfaces/keywords/tools/languages), `pricing`
|
||||
(`cpm|cpc|cpa|flat` + `bid`), `media`, `expires_at`.
|
||||
- Activating a campaign **escrows** `budget.total` (or a top-up tranche) in CoinPay so serving
|
||||
can never outrun funds.
|
||||
|
||||
### 4.2 Sell side
|
||||
- **Publisher account** keyed by `publisher_did`.
|
||||
- **Placement** (`agentad.placement`): `surface` (`cli|tui|agent|ci`), `accepted_formats`,
|
||||
`dimensions`, `context_tags`, `frequency_cap`, `allow_categories` / `block_categories`.
|
||||
- Publisher configures a **revenue share** and payout wallet; blocks disallowed categories.
|
||||
|
||||
### 4.3 The exchange (new surface, on top of the standard)
|
||||
1. **Match** — candidate ads whose `targeting` fits the placement `surface`, `context_tags`,
|
||||
`accepted_formats`, and pass its category blocks + `frequency_cap`.
|
||||
2. **Auction** — rank eligible candidates by effective value (`pricing.bid` normalized across
|
||||
`cpm/cpc/cpa`, weighted by advertiser reputation and predicted engagement). Second-price
|
||||
clearing in v1.
|
||||
3. **Pace** — enforce `budget.daily_cap` and remaining escrow before selecting a winner.
|
||||
4. **Serve** — return `agentad.ad_response` with the winning `ad`, a `rendered` string in the
|
||||
requested format, and a signed `impression_token`. On no candidate: `no_fill_reason`
|
||||
(`no_inventory | frequency_capped | blocked_category | invalid_request`).
|
||||
5. **Meter** — bill on the confirmed `agentad.impression` (CPM) or `agentad.click` (CPC), each
|
||||
validated against the signed token. CPA conversions post back via a click token.
|
||||
6. **Settle** — debit advertiser escrow, credit publisher (minus network fee) through CoinPay,
|
||||
emit reputation + audit events.
|
||||
|
||||
## 5. Functional requirements
|
||||
|
||||
### Advertiser
|
||||
- Create/update/pause/complete campaigns and ads; all writes validate against the AgentAd schemas.
|
||||
- Fund balance and escrow campaign budget via CoinPay; view remaining budget and daily pacing.
|
||||
- Targeting by `surface`, `keywords`, `tools` (e.g. `claude-code`, `gh`), `languages`, plus
|
||||
`exclude_keywords`.
|
||||
- Reporting: impressions, clicks, CTR, spend, CPA, by campaign/ad/surface/day.
|
||||
|
||||
### Publisher
|
||||
- Register/update/retire placements; set accepted formats, dimensions, frequency caps, category
|
||||
allow/block lists.
|
||||
- Request fills (`ad_request` → `ad_response`); render per surface (`[Sponsored]` header for
|
||||
humans, structured `sponsored: true` view for agents).
|
||||
- Confirm impressions/clicks with the returned tokens; view earnings and request payouts.
|
||||
|
||||
### Exchange / operator
|
||||
- Matching + second-price auction + budget pacing + frequency capping.
|
||||
- Token minting/verification for impressions and clicks (anti-fraud: unforgeable, single-use,
|
||||
bound to `request_id` + placement).
|
||||
- CoinPay integration for escrow, payout, and network fee; full audit log; reputation events.
|
||||
- No-fill accounting and reason codes; category policy enforcement.
|
||||
|
||||
### Consumer-facing rendering (delivery contract)
|
||||
- `consumer: human` → rendered string with a visible `[Sponsored]` / label header.
|
||||
- `consumer: agent` → structured object with explicit `sponsored: true`, `advertiser`, `title`,
|
||||
`url`, and the `machine_readable` `data` payload.
|
||||
|
||||
## 6. AgentBBS integration (reference publisher)
|
||||
|
||||
AgentBBS is the first-class publisher surface for M5:
|
||||
|
||||
- **Placements:** a menu/lounge banner (`surface: tui`, `banner`/`ansi`), a TUI side panel, and
|
||||
agent-chat inserts (`surface: agent`, `json`). Each registered as an `agentad.placement` under
|
||||
the BBS `publisher_did`.
|
||||
- **Frequency + taste:** conservative `frequency_cap` (e.g. one banner per session) and category
|
||||
blocks so the retro BBS UX stays clean; disclosure label rendered in `lipgloss` styling.
|
||||
- **Payouts:** BBS ad revenue settles to the operator wallet via CoinPay; can subsidize free pods
|
||||
/ lifetime tiers.
|
||||
- **Advertiser side:** BBS members with a `advertiser_did` can run campaigns targeting the CLI/agent
|
||||
ecosystem from inside the BBS.
|
||||
- **Delivery split (mirrors the ascii-live pattern):** the Go side (AgentBBS) renders and confirms
|
||||
tokens; the TS side (`@logicsrc/*` + the hosted network) owns campaigns, the auction, metering,
|
||||
and settlement.
|
||||
|
||||
## 7. Architecture & where it lives
|
||||
|
||||
| Component | Where | Language |
|
||||
| --- | --- | --- |
|
||||
| AgentAd schemas + validators | `packages/schemas`, `packages/validators` (exists) | JSON Schema / TS |
|
||||
| Marketplace SDK (campaign/placement/serve/meter clients) | `packages/sdk` (extend) | TS |
|
||||
| Exchange service (auction, pacing, metering, settlement) | cl1s.tech reference network | TS/Hono |
|
||||
| CoinPay escrow/payout hooks | `plugins/coinpay` (exists) | TS |
|
||||
| Publisher plugin surface | `plugins/agentbbs` (`PluginDefinition`: routes + events) | TS |
|
||||
| AgentBBS render/confirm | `profullstack/agentbbs` (Go: wish/bubbletea/lipgloss) | Go |
|
||||
|
||||
Schemas stay canonical under `https://schemas.logicsrc.com/`. The marketplace adds **services and
|
||||
account/listing/auction/billing state**, not new wire contracts — anything new (accounts, ledger,
|
||||
auction records) is reference-network internal until proven and promoted to the standard.
|
||||
|
||||
## 8. Data & settlement
|
||||
|
||||
- **Identity:** `advertiser_did` / `publisher_did` via CoinPay DID + ANS naming.
|
||||
- **Money:** budgets escrowed in CoinPay on campaign activation; per-event debits on confirmed
|
||||
impressions/clicks; publisher payout = revenue − network fee; refunds of unspent escrow on
|
||||
campaign completion.
|
||||
- **Reputation:** delivery quality (fill rate, valid-click ratio) and honored payments emit
|
||||
reputation events for both sides.
|
||||
- **Audit:** every serve/meter/settle action is logged; disclosure compliance is auditable.
|
||||
|
||||
## 9. Trust, safety & disclosure
|
||||
|
||||
- Disclosure enforced at serve time — the exchange refuses to emit an `ad_response` whose `ad`
|
||||
lacks `disclosure.sponsored: true`.
|
||||
- Publisher category blocks and advertiser `exclude_keywords` honored in matching.
|
||||
- Anti-fraud: single-use signed tokens bound to request + placement; rate limits; anomaly
|
||||
flags on click/impression ratios feed reputation.
|
||||
- Content policy: advertiser onboarding + campaign review; category taxonomy shared with
|
||||
publisher block lists.
|
||||
|
||||
## 10. Metrics
|
||||
|
||||
- **Liquidity:** fill rate, no-fill reasons distribution.
|
||||
- **Marketplace health:** active advertisers/publishers, escrowed budget, GMV, network-fee revenue.
|
||||
- **Delivery quality:** CTR, valid-impression/valid-click ratio, disclosure-compliance rate (target 100%).
|
||||
- **AgentBBS:** ad revenue per active session, opt-out / complaint rate.
|
||||
|
||||
## 11. Milestones
|
||||
|
||||
| Phase | Deliverable |
|
||||
| --- | --- |
|
||||
| **M5.0 — Contracts & SDK** | Marketplace SDK over existing AgentAd schemas; account/campaign/placement CRUD; validators wired. |
|
||||
| **M5.1 — Serve & meter** | `ad_request → ad_response` with matching, `frequency_cap`, signed impression/click tokens; no-fill reasons. |
|
||||
| **M5.2 — Auction & pacing** | Second-price auction, budget pacing against escrow + `daily_cap`, reputation-weighted ranking. |
|
||||
| **M5.3 — CoinPay settlement** | Escrow on activation, per-event billing, publisher payouts, network fee, refunds, audit + reputation events. |
|
||||
| **M5.4 — AgentBBS publisher** | BBS placements (banner/panel/agent-chat) rendering + token confirmation; operator payouts. |
|
||||
| **M5.5 — Advertiser & publisher consoles** | Reporting/dashboards (CLI + web) for both sides; hosted on cl1s.tech. |
|
||||
|
||||
## 12. Open questions
|
||||
|
||||
- Auction clearing: is second-price sufficient, or do we need reserve prices / floor CPMs per surface?
|
||||
- Currency: native CoinPay token vs. stablecoin denomination for budgets and payouts.
|
||||
- CPA attribution window and postback trust model across independent CLIs/agents.
|
||||
- Do publisher category taxonomies live in the standard, or stay reference-network config in v1?
|
||||
- Minimum reputation / KYC bar for advertisers before a campaign can serve.
|
||||
|
||||
## References
|
||||
|
||||
- [AgentAd standard](./agentad.md) — the primitive family and schemas this marketplace consumes.
|
||||
- `@logicsrc/schemas` — `agentad-*.schema.json` canonical contracts.
|
||||
- [cl1s.tech](https://github.com/profullstack/cl1s.tech) — reference hosted AgentAd network.
|
||||
- `plugins/coinpay` — DID, wallet, escrow, payout, reputation.
|
||||
- AgentBBS `docs/ascii-live.md` — the Go-surface / TS-plugin split pattern this PRD follows.
|
||||
|
|
@ -63,3 +63,9 @@ The `consumer` field on a request changes how an ad is delivered:
|
|||
|
||||
[cl1s.tech](https://github.com/profullstack/cl1s.tech) is the hosted AgentAd
|
||||
network: SDK, CLI, and ad-serving service that consume these schemas.
|
||||
|
||||
## Marketplace
|
||||
|
||||
The two-sided exchange built on these contracts — advertiser campaigns, publisher
|
||||
placements, auction, metering, and CoinPay settlement — is specified in
|
||||
[AgentAd Marketplace (PRD)](./agentad-marketplace.md) (AgentBBS milestone M5).
|
||||
|
|
|
|||
15
package-lock.json
generated
15
package-lock.json
generated
|
|
@ -1390,6 +1390,10 @@
|
|||
"resolved": "packages/account-core",
|
||||
"link": true
|
||||
},
|
||||
"node_modules/@logicsrc/agentad": {
|
||||
"resolved": "packages/agentad",
|
||||
"link": true
|
||||
},
|
||||
"node_modules/@logicsrc/agentstack": {
|
||||
"resolved": "packages/agentstack",
|
||||
"link": true
|
||||
|
|
@ -6486,6 +6490,17 @@
|
|||
"vitest": "^4.0.8"
|
||||
}
|
||||
},
|
||||
"packages/agentad": {
|
||||
"name": "@logicsrc/agentad",
|
||||
"version": "0.1.0",
|
||||
"dependencies": {
|
||||
"ajv": "^8.17.1",
|
||||
"ajv-formats": "^3.0.1"
|
||||
},
|
||||
"devDependencies": {
|
||||
"vitest": "^4.0.8"
|
||||
}
|
||||
},
|
||||
"packages/agentstack": {
|
||||
"name": "@logicsrc/agentstack",
|
||||
"version": "0.1.0",
|
||||
|
|
|
|||
|
|
@ -12,7 +12,7 @@
|
|||
"apps/*"
|
||||
],
|
||||
"scripts": {
|
||||
"build": "npm --workspace @logicsrc/schemas run build && npm --workspace @logicsrc/validators run build && npm --workspace @logicsrc/sdk run build && npm --workspace @logicsrc/ans run build && npm --workspace @logicsrc/plugin-core run build && npm --workspace @logicsrc/agentstack run build && npm --workspace @logicsrc/agentswarm run build && npm --workspace @logicsrc/account-core run build && npm --workspace @logicsrc/plugin-coinpay run build && npm --workspace @logicsrc/plugin-ugig run build && npm --workspace @logicsrc/plugin-sh1pt run build && npm --workspace @logicsrc/plugin-c0mpute run build && npm --workspace @logicsrc/plugin-feed-discovery run build && npm --workspace @logicsrc/plugin-social-accounts run build && npm --workspace @logicsrc/plugin-email-accounts run build && npm --workspace @logicsrc/plugin-agentgit run build && npm --workspace @logicsrc/plugin-agentmail run build && npm --workspace @logicsrc/plugin-credential-sharing run build && npm --workspace @logicsrc/tui run build && npm --workspace @logicsrc/cli run build && npm --workspace @profullstack/logicsrc-mcp run build && npm --workspace @logicsrc/commandboard-api run build && npm --workspace @logicsrc/commandboard-web run build && npm --workspace @logicsrc/web run build",
|
||||
"build": "npm --workspace @logicsrc/schemas run build && npm --workspace @logicsrc/validators run build && npm --workspace @logicsrc/sdk run build && npm --workspace @logicsrc/agentad run build && npm --workspace @logicsrc/ans run build && npm --workspace @logicsrc/plugin-core run build && npm --workspace @logicsrc/agentstack run build && npm --workspace @logicsrc/agentswarm run build && npm --workspace @logicsrc/account-core run build && npm --workspace @logicsrc/plugin-coinpay run build && npm --workspace @logicsrc/plugin-ugig run build && npm --workspace @logicsrc/plugin-sh1pt run build && npm --workspace @logicsrc/plugin-c0mpute run build && npm --workspace @logicsrc/plugin-feed-discovery run build && npm --workspace @logicsrc/plugin-social-accounts run build && npm --workspace @logicsrc/plugin-email-accounts run build && npm --workspace @logicsrc/plugin-agentgit run build && npm --workspace @logicsrc/plugin-agentmail run build && npm --workspace @logicsrc/plugin-credential-sharing run build && npm --workspace @logicsrc/tui run build && npm --workspace @logicsrc/cli run build && npm --workspace @profullstack/logicsrc-mcp run build && npm --workspace @logicsrc/commandboard-api run build && npm --workspace @logicsrc/commandboard-web run build && npm --workspace @logicsrc/web run build",
|
||||
"start": "npm --workspace @logicsrc/web run start",
|
||||
"test": "npm run test --workspaces --if-present",
|
||||
"check": "npm run build && npm run test",
|
||||
|
|
|
|||
85
packages/agentad/README.md
Normal file
85
packages/agentad/README.md
Normal file
|
|
@ -0,0 +1,85 @@
|
|||
# @logicsrc/agentad
|
||||
|
||||
Reference implementation of the **AgentAd Marketplace** (AgentBBS milestone **M5**),
|
||||
built on the AgentAd primitive family in `@logicsrc/schemas`.
|
||||
|
||||
- Standard: [`docs/agentad.md`](../../docs/agentad.md)
|
||||
- Product spec / PRD: [`docs/agentad-marketplace.md`](../../docs/agentad-marketplace.md)
|
||||
|
||||
It provides a two-sided exchange over the canonical schemas:
|
||||
|
||||
```
|
||||
match → auction (second price) → pace (budget) → serve → meter → settle
|
||||
```
|
||||
|
||||
Every served unit is disclosed (`disclosure.sponsored: true`) and carries a
|
||||
`machine_readable` payload for agent consumers. Metering is token-driven: serving
|
||||
mints a single-use, HMAC-signed `impression_token`; confirming the impression mints
|
||||
a `click_token`. Settlement is pluggable — `InMemorySettlement` here, CoinPay in
|
||||
production.
|
||||
|
||||
## Usage
|
||||
|
||||
```ts
|
||||
import {
|
||||
AgentAdExchange,
|
||||
createAd,
|
||||
createCampaign,
|
||||
createPlacement,
|
||||
createAdRequest,
|
||||
InMemorySettlement
|
||||
} from "@logicsrc/agentad";
|
||||
|
||||
const exchange = new AgentAdExchange({
|
||||
secret: process.env.AGENTAD_SECRET!,
|
||||
settlement: new InMemorySettlement({ networkFeeRate: 0.15 })
|
||||
});
|
||||
|
||||
// Advertiser side
|
||||
exchange.registerCampaign(
|
||||
createCampaign({
|
||||
advertiser_did: "railway.app",
|
||||
name: "CLI launch",
|
||||
status: "active",
|
||||
budget: { total: 100, currency: "USD" }
|
||||
})
|
||||
);
|
||||
exchange.registerAd(
|
||||
createAd({
|
||||
advertiser_did: "railway.app",
|
||||
campaign_id: /* campaign.id */ "cmp-...",
|
||||
format: "json",
|
||||
title: "Ship your CLI to production in 60s",
|
||||
url: "https://railway.app/?ref=cl1s",
|
||||
pricing: { model: "cpc", bid: 0.5, currency: "USD" },
|
||||
machine_readable: { product: "railway", install: "npm i -g @railway/cli" }
|
||||
})
|
||||
);
|
||||
|
||||
// Publisher side
|
||||
const placement = exchange.registerPlacement(
|
||||
createPlacement({
|
||||
publisher_did: "agentbbs.sh",
|
||||
surface: "agent",
|
||||
accepted_formats: ["json"],
|
||||
frequency_cap: { max_per_session: 1 }
|
||||
})
|
||||
);
|
||||
|
||||
// Serve → meter
|
||||
const res = exchange.requestAds(
|
||||
createAdRequest({ placement_id: placement.id, consumer: "agent" })
|
||||
);
|
||||
const { impression_token } = res.ads[0];
|
||||
const { click_token } = exchange.confirmImpression(impression_token);
|
||||
exchange.confirmClick(click_token, { action: "open_url" });
|
||||
|
||||
exchange.earnings("agentbbs.sh"); // publisher payout, net of network fee
|
||||
```
|
||||
|
||||
## Scripts
|
||||
|
||||
```bash
|
||||
npm --workspace @logicsrc/agentad run build
|
||||
npm --workspace @logicsrc/agentad run test
|
||||
```
|
||||
19
packages/agentad/package.json
Normal file
19
packages/agentad/package.json
Normal file
|
|
@ -0,0 +1,19 @@
|
|||
{
|
||||
"name": "@logicsrc/agentad",
|
||||
"version": "0.1.0",
|
||||
"description": "AgentAd Marketplace reference exchange: campaign/placement registration, second-price auction, budget pacing, signed impression/click metering, and CoinPay-style settlement over the @logicsrc AgentAd schemas.",
|
||||
"type": "module",
|
||||
"main": "./dist/index.js",
|
||||
"types": "./dist/index.d.ts",
|
||||
"scripts": {
|
||||
"build": "tsc -p tsconfig.json",
|
||||
"test": "vitest run src"
|
||||
},
|
||||
"dependencies": {
|
||||
"ajv": "^8.17.1",
|
||||
"ajv-formats": "^3.0.1"
|
||||
},
|
||||
"devDependencies": {
|
||||
"vitest": "^4.0.8"
|
||||
}
|
||||
}
|
||||
62
packages/agentad/src/builders.test.ts
Normal file
62
packages/agentad/src/builders.test.ts
Normal file
|
|
@ -0,0 +1,62 @@
|
|||
import { describe, expect, it } from "vitest";
|
||||
import { createAd, createCampaign, createPlacement, createAdRequest } from "./builders.js";
|
||||
import { validate } from "./validate.js";
|
||||
|
||||
describe("AgentAd builders", () => {
|
||||
it("builds a schema-valid, disclosed ad and always forces sponsored:true", () => {
|
||||
const ad = createAd({
|
||||
advertiser_did: "railway.app",
|
||||
campaign_id: "cmp-1",
|
||||
format: "json",
|
||||
title: "Ship your CLI in 60s",
|
||||
url: "https://railway.app/?ref=cl1s",
|
||||
pricing: { model: "cpc", bid: 0.5, currency: "USD" },
|
||||
machine_readable: { product: "railway" }
|
||||
});
|
||||
|
||||
expect(ad.type).toBe("agentad.ad");
|
||||
expect(ad.disclosure).toEqual({ sponsored: true, label: "Sponsored" });
|
||||
expect(validate("agentad-ad", ad).ok).toBe(true);
|
||||
});
|
||||
|
||||
it("honors a custom disclosure label and advertiser name", () => {
|
||||
const ad = createAd({
|
||||
advertiser_did: "acme.dev",
|
||||
format: "text",
|
||||
title: "Acme",
|
||||
url: "https://acme.dev",
|
||||
disclosure: { label: "Ad", advertiser_name: "Acme, Inc." }
|
||||
});
|
||||
expect(ad.disclosure.label).toBe("Ad");
|
||||
expect(ad.disclosure.advertiser_name).toBe("Acme, Inc.");
|
||||
expect(ad.disclosure.sponsored).toBe(true);
|
||||
});
|
||||
|
||||
it("defaults a campaign to draft and validates", () => {
|
||||
const campaign = createCampaign({
|
||||
advertiser_did: "railway.app",
|
||||
name: "Launch",
|
||||
budget: { total: 100, currency: "USD" }
|
||||
});
|
||||
expect(campaign.status).toBe("draft");
|
||||
expect(validate("agentad-campaign", campaign).ok).toBe(true);
|
||||
});
|
||||
|
||||
it("builds valid placements and requests", () => {
|
||||
const placement = createPlacement({
|
||||
publisher_did: "agentbbs.sh",
|
||||
surface: "agent",
|
||||
accepted_formats: ["json"]
|
||||
});
|
||||
const request = createAdRequest({ placement_id: placement.id, consumer: "agent" });
|
||||
|
||||
expect(validate("agentad-placement", placement).ok).toBe(true);
|
||||
expect(validate("agentad-ad-request", request).ok).toBe(true);
|
||||
});
|
||||
|
||||
it("generates unique ids across calls", () => {
|
||||
const a = createPlacement({ publisher_did: "p.p", surface: "cli", accepted_formats: ["text"] });
|
||||
const b = createPlacement({ publisher_did: "p.p", surface: "cli", accepted_formats: ["text"] });
|
||||
expect(a.id).not.toBe(b.id);
|
||||
});
|
||||
});
|
||||
90
packages/agentad/src/builders.ts
Normal file
90
packages/agentad/src/builders.ts
Normal file
|
|
@ -0,0 +1,90 @@
|
|||
// Convenience builders that produce schema-valid AgentAd documents with sensible
|
||||
// LogicSRC defaults. Every builder fills `type`/`version` and — for ads — enforces
|
||||
// the mandatory disclosure contract so an undisclosed ad can never be constructed.
|
||||
|
||||
import type { Ad, AdRequest, Campaign, Placement } from "./types.js";
|
||||
import { assertValid } from "./validate.js";
|
||||
|
||||
export const AGENTAD_VERSION = "0.1";
|
||||
|
||||
let counter = 0;
|
||||
function autoId(prefix: string): string {
|
||||
counter += 1;
|
||||
return `${prefix}-${Date.now().toString(36)}-${counter.toString(36)}`;
|
||||
}
|
||||
|
||||
export type NewAd = Omit<Ad, "type" | "version" | "id" | "disclosure"> & {
|
||||
id?: string;
|
||||
version?: string;
|
||||
disclosure?: Partial<Ad["disclosure"]>;
|
||||
};
|
||||
|
||||
export function createAd(input: NewAd): Ad {
|
||||
const ad: Ad = {
|
||||
type: "agentad.ad",
|
||||
version: input.version ?? AGENTAD_VERSION,
|
||||
id: input.id ?? autoId("ad"),
|
||||
...stripMeta(input),
|
||||
disclosure: {
|
||||
sponsored: true,
|
||||
label: input.disclosure?.label ?? "Sponsored",
|
||||
...(input.disclosure?.advertiser_name
|
||||
? { advertiser_name: input.disclosure.advertiser_name }
|
||||
: {})
|
||||
}
|
||||
};
|
||||
return assertValid("agentad-ad", ad);
|
||||
}
|
||||
|
||||
export type NewCampaign = Omit<Campaign, "type" | "version" | "id"> & {
|
||||
id?: string;
|
||||
version?: string;
|
||||
};
|
||||
|
||||
export function createCampaign(input: NewCampaign): Campaign {
|
||||
const campaign: Campaign = {
|
||||
type: "agentad.campaign",
|
||||
version: input.version ?? AGENTAD_VERSION,
|
||||
id: input.id ?? autoId("cmp"),
|
||||
status: input.status ?? "draft",
|
||||
...stripMeta(input)
|
||||
};
|
||||
return assertValid("agentad-campaign", campaign);
|
||||
}
|
||||
|
||||
export type NewPlacement = Omit<Placement, "type" | "version" | "id"> & {
|
||||
id?: string;
|
||||
version?: string;
|
||||
};
|
||||
|
||||
export function createPlacement(input: NewPlacement): Placement {
|
||||
const placement: Placement = {
|
||||
type: "agentad.placement",
|
||||
version: input.version ?? AGENTAD_VERSION,
|
||||
id: input.id ?? autoId("plc"),
|
||||
...stripMeta(input)
|
||||
};
|
||||
return assertValid("agentad-placement", placement);
|
||||
}
|
||||
|
||||
export type NewAdRequest = Omit<AdRequest, "type" | "version"> & { version?: string };
|
||||
|
||||
export function createAdRequest(input: NewAdRequest): AdRequest {
|
||||
const request: AdRequest = {
|
||||
type: "agentad.ad_request",
|
||||
version: input.version ?? AGENTAD_VERSION,
|
||||
...stripMeta(input)
|
||||
};
|
||||
return assertValid("agentad-ad-request", request);
|
||||
}
|
||||
|
||||
// Drop the builder-only override keys so they don't leak into the document and
|
||||
// trip additionalProperties:false during validation.
|
||||
function stripMeta<T extends Record<string, unknown>>(input: T): Omit<T, "version" | "id" | "disclosure" | "status"> {
|
||||
const { version, id, disclosure, status, ...rest } = input as Record<string, unknown>;
|
||||
void version;
|
||||
void id;
|
||||
void disclosure;
|
||||
void status;
|
||||
return rest as Omit<T, "version" | "id" | "disclosure" | "status">;
|
||||
}
|
||||
338
packages/agentad/src/exchange.test.ts
Normal file
338
packages/agentad/src/exchange.test.ts
Normal file
|
|
@ -0,0 +1,338 @@
|
|||
import { beforeEach, describe, expect, it } from "vitest";
|
||||
import { AgentAdExchange } from "./exchange.js";
|
||||
import { InMemorySettlement } from "./settlement.js";
|
||||
import { createAd, createCampaign, createPlacement, createAdRequest } from "./builders.js";
|
||||
import { validate } from "./validate.js";
|
||||
import type { Ad } from "./types.js";
|
||||
|
||||
const NOW = Date.parse("2026-07-01T00:00:00Z");
|
||||
const clock = () => NOW;
|
||||
|
||||
function newExchange(feeRate = 0.15) {
|
||||
return new AgentAdExchange({
|
||||
secret: "test-secret",
|
||||
now: clock,
|
||||
settlement: new InMemorySettlement({ networkFeeRate: feeRate })
|
||||
});
|
||||
}
|
||||
|
||||
function jsonPlacement(exchange: AgentAdExchange, overrides = {}) {
|
||||
return exchange.registerPlacement(
|
||||
createPlacement({
|
||||
publisher_did: "agentbbs.sh",
|
||||
surface: "agent",
|
||||
accepted_formats: ["json"],
|
||||
...overrides
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
function activeCampaign(exchange: AgentAdExchange, total = 100) {
|
||||
const campaign = createCampaign({
|
||||
advertiser_did: "railway.app",
|
||||
name: "Launch",
|
||||
status: "active",
|
||||
budget: { total, currency: "USD" }
|
||||
});
|
||||
return exchange.registerCampaign(campaign);
|
||||
}
|
||||
|
||||
describe("AgentAdExchange serving", () => {
|
||||
let exchange: AgentAdExchange;
|
||||
|
||||
beforeEach(() => {
|
||||
exchange = newExchange();
|
||||
});
|
||||
|
||||
it("serves a matching, disclosed ad and produces a schema-valid response", () => {
|
||||
const campaign = activeCampaign(exchange);
|
||||
exchange.registerAd(
|
||||
createAd({
|
||||
advertiser_did: "railway.app",
|
||||
campaign_id: campaign.id,
|
||||
format: "json",
|
||||
title: "Ship your CLI in 60s",
|
||||
url: "https://railway.app/?ref=cl1s",
|
||||
pricing: { model: "cpc", bid: 0.5, currency: "USD" },
|
||||
machine_readable: { product: "railway" }
|
||||
})
|
||||
);
|
||||
const placement = jsonPlacement(exchange);
|
||||
|
||||
const res = exchange.requestAds(
|
||||
createAdRequest({ placement_id: placement.id, consumer: "agent" })
|
||||
);
|
||||
|
||||
expect(res.no_fill_reason).toBeUndefined();
|
||||
expect(res.ads).toHaveLength(1);
|
||||
expect(validate("agentad-ad-response", res).ok).toBe(true);
|
||||
|
||||
const rendered = JSON.parse(res.ads[0].rendered ?? "{}");
|
||||
expect(rendered.sponsored).toBe(true);
|
||||
expect(rendered.data.product).toBe("railway");
|
||||
});
|
||||
|
||||
it("no-fills with no_inventory when nothing matches", () => {
|
||||
jsonPlacement(exchange);
|
||||
const placementId = jsonPlacement(exchange).id;
|
||||
const res = exchange.requestAds(createAdRequest({ placement_id: placementId }));
|
||||
expect(res.ads).toHaveLength(0);
|
||||
expect(res.no_fill_reason).toBe("no_inventory");
|
||||
});
|
||||
|
||||
it("no-fills with invalid_request for an unknown placement", () => {
|
||||
const res = exchange.requestAds(createAdRequest({ placement_id: "does-not-exist" }));
|
||||
expect(res.no_fill_reason).toBe("invalid_request");
|
||||
});
|
||||
|
||||
it("does not serve ads from draft campaigns", () => {
|
||||
const draft = exchange.registerCampaign(
|
||||
createCampaign({
|
||||
advertiser_did: "railway.app",
|
||||
name: "Draft",
|
||||
budget: { total: 100, currency: "USD" }
|
||||
})
|
||||
);
|
||||
exchange.registerAd(
|
||||
createAd({
|
||||
advertiser_did: "railway.app",
|
||||
campaign_id: draft.id,
|
||||
format: "json",
|
||||
title: "Draft ad",
|
||||
url: "https://x.dev",
|
||||
pricing: { model: "cpm", bid: 5, currency: "USD" }
|
||||
})
|
||||
);
|
||||
const placement = jsonPlacement(exchange);
|
||||
expect(exchange.requestAds(createAdRequest({ placement_id: placement.id })).no_fill_reason).toBe(
|
||||
"no_inventory"
|
||||
);
|
||||
});
|
||||
|
||||
it("blocks ads whose category is on the placement block list", () => {
|
||||
const campaign = activeCampaign(exchange);
|
||||
exchange.registerAd(
|
||||
createAd({
|
||||
advertiser_did: "railway.app",
|
||||
campaign_id: campaign.id,
|
||||
format: "json",
|
||||
title: "Buy coins",
|
||||
url: "https://coins.example",
|
||||
pricing: { model: "cpm", bid: 5, currency: "USD" },
|
||||
machine_readable: { category: "crypto" }
|
||||
})
|
||||
);
|
||||
const placement = jsonPlacement(exchange, { block_categories: ["crypto"] });
|
||||
expect(exchange.requestAds(createAdRequest({ placement_id: placement.id })).no_fill_reason).toBe(
|
||||
"blocked_category"
|
||||
);
|
||||
});
|
||||
|
||||
it("refuses to register an undisclosed ad", () => {
|
||||
const campaign = activeCampaign(exchange);
|
||||
const undisclosed = {
|
||||
type: "agentad.ad",
|
||||
version: "0.1",
|
||||
id: "ad-bad",
|
||||
campaign_id: campaign.id,
|
||||
advertiser_did: "railway.app",
|
||||
format: "text",
|
||||
title: "Sneaky",
|
||||
url: "https://x.dev",
|
||||
disclosure: { sponsored: false, label: "Sponsored" }
|
||||
} as unknown as Ad;
|
||||
expect(() => exchange.registerAd(undisclosed)).toThrow();
|
||||
});
|
||||
});
|
||||
|
||||
describe("AgentAdExchange metering & settlement", () => {
|
||||
it("charges CPC on click (not impression) and pays the publisher net of fee", () => {
|
||||
const exchange = newExchange(0.15);
|
||||
const campaign = activeCampaign(exchange);
|
||||
exchange.registerAd(
|
||||
createAd({
|
||||
advertiser_did: "railway.app",
|
||||
campaign_id: campaign.id,
|
||||
format: "json",
|
||||
title: "CPC ad",
|
||||
url: "https://railway.app",
|
||||
pricing: { model: "cpc", bid: 1, currency: "USD" }
|
||||
})
|
||||
);
|
||||
const placement = jsonPlacement(exchange);
|
||||
|
||||
const res = exchange.requestAds(createAdRequest({ placement_id: placement.id }));
|
||||
const imp = exchange.confirmImpression(res.ads[0].impression_token);
|
||||
expect(imp.charged).toBe(0); // cpc is billed on click
|
||||
expect(validate("agentad-impression", imp.impression).ok).toBe(true);
|
||||
|
||||
const click = exchange.confirmClick(imp.click_token, { action: "open_url" });
|
||||
expect(validate("agentad-click", click).ok).toBe(true);
|
||||
|
||||
// single candidate → pays own bid ($1); publisher gets 85%
|
||||
expect(exchange.earnings("agentbbs.sh")).toBeCloseTo(0.85, 6);
|
||||
expect(exchange.remaining(campaign.id)).toBeCloseTo(99, 6);
|
||||
expect(exchange.ledger().filter((e) => e.kind === "click" && e.amount > 0)).toHaveLength(1);
|
||||
});
|
||||
|
||||
it("charges CPM on impression", () => {
|
||||
const exchange = newExchange(0);
|
||||
const campaign = activeCampaign(exchange, 100);
|
||||
exchange.registerAd(
|
||||
createAd({
|
||||
advertiser_did: "railway.app",
|
||||
campaign_id: campaign.id,
|
||||
format: "json",
|
||||
title: "CPM ad",
|
||||
url: "https://railway.app",
|
||||
pricing: { model: "cpm", bid: 10, currency: "USD" } // $10 CPM = $0.01 / impression
|
||||
})
|
||||
);
|
||||
const placement = jsonPlacement(exchange);
|
||||
const res = exchange.requestAds(createAdRequest({ placement_id: placement.id }));
|
||||
const imp = exchange.confirmImpression(res.ads[0].impression_token);
|
||||
expect(imp.charged).toBeCloseTo(0.01, 6);
|
||||
expect(exchange.earnings("agentbbs.sh")).toBeCloseTo(0.01, 6); // 0% fee
|
||||
});
|
||||
|
||||
it("runs a second-price auction: the winner pays the runner-up's price", () => {
|
||||
const exchange = newExchange(0);
|
||||
const high = exchange.registerCampaign(
|
||||
createCampaign({
|
||||
advertiser_did: "high.dev",
|
||||
name: "High",
|
||||
status: "active",
|
||||
budget: { total: 100, currency: "USD" }
|
||||
})
|
||||
);
|
||||
const low = exchange.registerCampaign(
|
||||
createCampaign({
|
||||
advertiser_did: "low.dev",
|
||||
name: "Low",
|
||||
status: "active",
|
||||
budget: { total: 100, currency: "USD" }
|
||||
})
|
||||
);
|
||||
exchange.registerAd(
|
||||
createAd({
|
||||
advertiser_did: "high.dev",
|
||||
campaign_id: high.id,
|
||||
format: "json",
|
||||
title: "High bid",
|
||||
url: "https://high.dev",
|
||||
pricing: { model: "cpc", bid: 1, currency: "USD" }
|
||||
})
|
||||
);
|
||||
exchange.registerAd(
|
||||
createAd({
|
||||
advertiser_did: "low.dev",
|
||||
campaign_id: low.id,
|
||||
format: "json",
|
||||
title: "Low bid",
|
||||
url: "https://low.dev",
|
||||
pricing: { model: "cpc", bid: 0.5, currency: "USD" }
|
||||
})
|
||||
);
|
||||
const placement = jsonPlacement(exchange);
|
||||
|
||||
const res = exchange.requestAds(createAdRequest({ placement_id: placement.id }));
|
||||
expect(res.ads).toHaveLength(1);
|
||||
// winner is high.dev; clearing factor = 0.5/1 → charged 0.5 on click
|
||||
const imp = exchange.confirmImpression(res.ads[0].impression_token);
|
||||
exchange.confirmClick(imp.click_token);
|
||||
expect(exchange.remaining(high.id)).toBeCloseTo(99.5, 6);
|
||||
expect(exchange.remaining(low.id)).toBeCloseTo(100, 6); // runner-up not charged
|
||||
});
|
||||
|
||||
it("never charges beyond escrowed budget", () => {
|
||||
const exchange = newExchange(0);
|
||||
const campaign = exchange.registerCampaign(
|
||||
createCampaign({
|
||||
advertiser_did: "railway.app",
|
||||
name: "Tiny",
|
||||
status: "active",
|
||||
budget: { total: 0.005, currency: "USD" }
|
||||
})
|
||||
);
|
||||
exchange.registerAd(
|
||||
createAd({
|
||||
advertiser_did: "railway.app",
|
||||
campaign_id: campaign.id,
|
||||
format: "json",
|
||||
title: "CPM ad",
|
||||
url: "https://railway.app",
|
||||
pricing: { model: "cpm", bid: 10, currency: "USD" } // wants $0.01 / impression
|
||||
})
|
||||
);
|
||||
const placement = jsonPlacement(exchange);
|
||||
|
||||
const res = exchange.requestAds(createAdRequest({ placement_id: placement.id }));
|
||||
const imp = exchange.confirmImpression(res.ads[0].impression_token);
|
||||
expect(imp.charged).toBeCloseTo(0.005, 6); // capped at escrow
|
||||
expect(exchange.remaining(campaign.id)).toBe(0);
|
||||
|
||||
// budget exhausted → no more inventory
|
||||
expect(exchange.requestAds(createAdRequest({ placement_id: placement.id })).no_fill_reason).toBe(
|
||||
"no_inventory"
|
||||
);
|
||||
});
|
||||
|
||||
it("rejects reused and forged tokens", () => {
|
||||
const exchange = newExchange();
|
||||
const campaign = activeCampaign(exchange);
|
||||
exchange.registerAd(
|
||||
createAd({
|
||||
advertiser_did: "railway.app",
|
||||
campaign_id: campaign.id,
|
||||
format: "json",
|
||||
title: "Ad",
|
||||
url: "https://railway.app",
|
||||
pricing: { model: "cpc", bid: 1, currency: "USD" }
|
||||
})
|
||||
);
|
||||
const placement = jsonPlacement(exchange);
|
||||
const res = exchange.requestAds(createAdRequest({ placement_id: placement.id }));
|
||||
const token = res.ads[0].impression_token;
|
||||
|
||||
exchange.confirmImpression(token);
|
||||
expect(() => exchange.confirmImpression(token)).toThrow(/already consumed/);
|
||||
expect(() => exchange.confirmImpression("garbage.token")).toThrow(/invalid impression token/);
|
||||
});
|
||||
});
|
||||
|
||||
describe("AgentAdExchange frequency capping", () => {
|
||||
it("caps impressions per session", () => {
|
||||
const exchange = newExchange();
|
||||
const campaign = activeCampaign(exchange);
|
||||
exchange.registerAd(
|
||||
createAd({
|
||||
advertiser_did: "railway.app",
|
||||
campaign_id: campaign.id,
|
||||
format: "json",
|
||||
title: "Ad",
|
||||
url: "https://railway.app",
|
||||
pricing: { model: "cpm", bid: 5, currency: "USD" }
|
||||
})
|
||||
);
|
||||
const placement = jsonPlacement(exchange, { frequency_cap: { max_per_session: 1 } });
|
||||
|
||||
const first = exchange.requestAds(
|
||||
createAdRequest({ placement_id: placement.id }),
|
||||
{ sessionId: "s1" }
|
||||
);
|
||||
expect(first.ads).toHaveLength(1);
|
||||
|
||||
const second = exchange.requestAds(
|
||||
createAdRequest({ placement_id: placement.id }),
|
||||
{ sessionId: "s1" }
|
||||
);
|
||||
expect(second.no_fill_reason).toBe("frequency_capped");
|
||||
|
||||
// a different session is unaffected
|
||||
const other = exchange.requestAds(
|
||||
createAdRequest({ placement_id: placement.id }),
|
||||
{ sessionId: "s2" }
|
||||
);
|
||||
expect(other.ads).toHaveLength(1);
|
||||
});
|
||||
});
|
||||
544
packages/agentad/src/exchange.ts
Normal file
544
packages/agentad/src/exchange.ts
Normal file
|
|
@ -0,0 +1,544 @@
|
|||
// AgentAdExchange — the reference two-sided exchange described in
|
||||
// docs/agentad-marketplace.md. It registers advertiser campaigns/ads and
|
||||
// publisher placements, then for each ad request runs:
|
||||
//
|
||||
// match -> auction (second price) -> pace (budget) -> serve -> meter -> settle
|
||||
//
|
||||
// Metering is token-driven: serving mints a single-use, HMAC-signed
|
||||
// impression_token; confirming the impression mints a click_token. Settlement is
|
||||
// pluggable (CoinPay in production; in-memory here).
|
||||
|
||||
import { AGENTAD_VERSION } from "./builders.js";
|
||||
import { InMemorySettlement, type SettlementProvider } from "./settlement.js";
|
||||
import { mintToken, verifyToken } from "./tokens.js";
|
||||
import type {
|
||||
Ad,
|
||||
AdRequest,
|
||||
AdResponse,
|
||||
Campaign,
|
||||
CampaignStatus,
|
||||
Click,
|
||||
ClickAction,
|
||||
Consumer,
|
||||
Impression,
|
||||
NoFillReason,
|
||||
Placement,
|
||||
ServedAd
|
||||
} from "./types.js";
|
||||
import { assertValid } from "./validate.js";
|
||||
|
||||
export interface LedgerEntry {
|
||||
kind: "impression" | "click";
|
||||
campaign_id: string;
|
||||
ad_id: string;
|
||||
placement_id: string;
|
||||
action?: ClickAction;
|
||||
amount: number;
|
||||
currency: string;
|
||||
occurred_at: string;
|
||||
}
|
||||
|
||||
export interface AgentAdExchangeOptions {
|
||||
/** HMAC secret used to sign tracking tokens. */
|
||||
secret: string;
|
||||
/** Settlement backend. Defaults to a fresh InMemorySettlement. */
|
||||
settlement?: SettlementProvider;
|
||||
/** Clock, for deterministic tests. */
|
||||
now?: () => number;
|
||||
/** Token lifetime in ms. Default 24h. */
|
||||
tokenTtlMs?: number;
|
||||
/** Expected click-through rate for normalizing cpc/cpa bids. Default 0.02. */
|
||||
expectedCtr?: number;
|
||||
/** Expected conversion rate for normalizing cpa bids. Default 0.05. */
|
||||
expectedCvr?: number;
|
||||
/** Weight applied to keyword relevance in ranking. Default 0.25. */
|
||||
relevanceWeight?: number;
|
||||
}
|
||||
|
||||
export interface RequestOptions {
|
||||
now?: number;
|
||||
/** Session identifier for per-session frequency capping. Default "default". */
|
||||
sessionId?: string;
|
||||
}
|
||||
|
||||
export interface ConfirmImpressionResult {
|
||||
impression: Impression;
|
||||
click_token: string;
|
||||
charged: number;
|
||||
}
|
||||
|
||||
let requestCounter = 0;
|
||||
|
||||
export class AgentAdExchange {
|
||||
private readonly secret: string;
|
||||
private readonly settlement: SettlementProvider;
|
||||
private readonly clock: () => number;
|
||||
private readonly tokenTtlMs: number;
|
||||
private readonly expectedCtr: number;
|
||||
private readonly expectedCvr: number;
|
||||
private readonly relevanceWeight: number;
|
||||
|
||||
private readonly ads = new Map<string, Ad>();
|
||||
private readonly campaigns = new Map<string, Campaign>();
|
||||
private readonly placements = new Map<string, Placement>();
|
||||
private readonly escrowed = new Set<string>();
|
||||
private readonly reputation = new Map<string, number>();
|
||||
|
||||
private readonly usedTokens = new Set<string>();
|
||||
private readonly sessionCount = new Map<string, number>();
|
||||
private readonly dayCount = new Map<string, number>();
|
||||
private readonly dailySpend = new Map<string, number>();
|
||||
private readonly ledgerEntries: LedgerEntry[] = [];
|
||||
|
||||
constructor(options: AgentAdExchangeOptions) {
|
||||
if (!options.secret) throw new Error("AgentAdExchange requires a signing secret");
|
||||
this.secret = options.secret;
|
||||
this.settlement = options.settlement ?? new InMemorySettlement();
|
||||
this.clock = options.now ?? (() => Date.now());
|
||||
this.tokenTtlMs = options.tokenTtlMs ?? 24 * 60 * 60 * 1000;
|
||||
this.expectedCtr = options.expectedCtr ?? 0.02;
|
||||
this.expectedCvr = options.expectedCvr ?? 0.05;
|
||||
this.relevanceWeight = options.relevanceWeight ?? 0.25;
|
||||
}
|
||||
|
||||
// --- registration -------------------------------------------------------
|
||||
|
||||
registerAd(ad: Ad): Ad {
|
||||
assertValid("agentad-ad", ad);
|
||||
if (ad.disclosure.sponsored !== true) {
|
||||
throw new Error(`ad ${ad.id} is not disclosed as sponsored`);
|
||||
}
|
||||
this.ads.set(ad.id, ad);
|
||||
return ad;
|
||||
}
|
||||
|
||||
registerCampaign(campaign: Campaign): Campaign {
|
||||
assertValid("agentad-campaign", campaign);
|
||||
this.campaigns.set(campaign.id, campaign);
|
||||
if ((campaign.status ?? "draft") === "active") this.escrowCampaign(campaign);
|
||||
return campaign;
|
||||
}
|
||||
|
||||
registerPlacement(placement: Placement): Placement {
|
||||
assertValid("agentad-placement", placement);
|
||||
this.placements.set(placement.id, placement);
|
||||
return placement;
|
||||
}
|
||||
|
||||
setCampaignStatus(campaignId: string, status: CampaignStatus): void {
|
||||
const campaign = this.campaigns.get(campaignId);
|
||||
if (!campaign) throw new Error(`unknown campaign ${campaignId}`);
|
||||
campaign.status = status;
|
||||
if (status === "active") this.escrowCampaign(campaign);
|
||||
}
|
||||
|
||||
setReputation(advertiserDid: string, score: number): void {
|
||||
if (score <= 0) throw new Error("reputation must be > 0");
|
||||
this.reputation.set(advertiserDid, score);
|
||||
}
|
||||
|
||||
private escrowCampaign(campaign: Campaign): void {
|
||||
if (this.escrowed.has(campaign.id)) return;
|
||||
this.settlement.escrow(
|
||||
campaign.id,
|
||||
campaign.advertiser_did,
|
||||
campaign.budget.total,
|
||||
campaign.budget.currency
|
||||
);
|
||||
this.escrowed.add(campaign.id);
|
||||
}
|
||||
|
||||
// --- serving ------------------------------------------------------------
|
||||
|
||||
requestAds(request: AdRequest, opts: RequestOptions = {}): AdResponse {
|
||||
const requestId = `req-${(requestCounter += 1).toString(36)}-${this.clock().toString(36)}`;
|
||||
const validation = assertValidSafe("agentad-ad-request", request);
|
||||
if (!validation) return this.noFill(requestId, "invalid_request");
|
||||
|
||||
const placement = this.placements.get(request.placement_id);
|
||||
if (!placement) return this.noFill(requestId, "invalid_request");
|
||||
|
||||
const now = opts.now ?? this.clock();
|
||||
const day = dayKey(now);
|
||||
const session = opts.sessionId ?? "default";
|
||||
const consumer: Consumer = request.consumer ?? "human";
|
||||
|
||||
const freqRemaining = this.frequencyRemaining(placement, session, day);
|
||||
if (freqRemaining <= 0) return this.noFill(requestId, "frequency_capped");
|
||||
|
||||
const ctx = contextKeywords(placement, request);
|
||||
let blockedByCategory = false;
|
||||
const candidates: Candidate[] = [];
|
||||
|
||||
for (const ad of this.ads.values()) {
|
||||
const campaign = ad.campaign_id ? this.campaigns.get(ad.campaign_id) : undefined;
|
||||
if (!campaign || (campaign.status ?? "draft") !== "active") continue;
|
||||
if (isExpired(ad, now)) continue;
|
||||
|
||||
if (request.format_override && ad.format !== request.format_override) continue;
|
||||
if (!placement.accepted_formats.includes(ad.format)) continue;
|
||||
|
||||
if (ad.targeting?.surfaces && !ad.targeting.surfaces.includes(placement.surface)) continue;
|
||||
|
||||
const category = adCategory(ad);
|
||||
if (category && placement.block_categories?.includes(category)) {
|
||||
blockedByCategory = true;
|
||||
continue;
|
||||
}
|
||||
if (category && placement.allow_categories && !placement.allow_categories.includes(category)) {
|
||||
blockedByCategory = true;
|
||||
continue;
|
||||
}
|
||||
|
||||
if (ad.targeting?.exclude_keywords?.some((k) => ctx.has(k.toLowerCase()))) continue;
|
||||
|
||||
if (this.settlement.remaining(campaign.id) <= 0) continue;
|
||||
if (
|
||||
campaign.budget.daily_cap != null &&
|
||||
(this.dailySpend.get(`${campaign.id}:${day}`) ?? 0) >= campaign.budget.daily_cap
|
||||
) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const relevance = overlapCount(ad.targeting?.keywords, ctx);
|
||||
const ev = this.effectiveValue(ad);
|
||||
const rep = this.reputation.get(ad.advertiser_did) ?? 1;
|
||||
const score = ev * (1 + this.relevanceWeight * relevance) * rep;
|
||||
candidates.push({ ad, campaign, ev, score });
|
||||
}
|
||||
|
||||
if (candidates.length === 0) {
|
||||
return this.noFill(requestId, blockedByCategory ? "blocked_category" : "no_inventory");
|
||||
}
|
||||
|
||||
candidates.sort((a, b) => b.score - a.score || b.ev - a.ev || a.ad.id.localeCompare(b.ad.id));
|
||||
|
||||
const wanted = Math.min(request.count ?? 1, candidates.length, freqRemaining);
|
||||
const served: ServedAd[] = [];
|
||||
const exp = now + this.tokenTtlMs;
|
||||
|
||||
for (let i = 0; i < wanted; i += 1) {
|
||||
const winner = candidates[i];
|
||||
const next = candidates[i + 1];
|
||||
const clearingFactor = next && winner.ev > 0 ? clamp(next.ev / winner.ev, 0, 1) : 1;
|
||||
const charge = this.unitCharge(winner.ad, clearingFactor);
|
||||
const currency = winner.ad.pricing?.currency ?? winner.campaign.budget.currency;
|
||||
|
||||
const token = mintToken(this.secret, {
|
||||
k: "impression",
|
||||
rid: requestId,
|
||||
pid: placement.id,
|
||||
aid: winner.ad.id,
|
||||
cid: winner.campaign.id,
|
||||
model: winner.ad.pricing?.model ?? "flat",
|
||||
charge,
|
||||
cur: currency,
|
||||
exp,
|
||||
n: nonce()
|
||||
});
|
||||
|
||||
served.push({
|
||||
ad: winner.ad,
|
||||
impression_token: token,
|
||||
rendered: render(winner.ad, consumer)
|
||||
});
|
||||
this.bumpFrequency(placement, session, day);
|
||||
}
|
||||
|
||||
const response: AdResponse = {
|
||||
type: "agentad.ad_response",
|
||||
version: AGENTAD_VERSION,
|
||||
request_id: requestId,
|
||||
ads: served
|
||||
};
|
||||
assertValid("agentad-ad-response", response);
|
||||
return response;
|
||||
}
|
||||
|
||||
// --- metering -----------------------------------------------------------
|
||||
|
||||
confirmImpression(
|
||||
token: string,
|
||||
opts: { now?: number; consumer?: Consumer } = {}
|
||||
): ConfirmImpressionResult {
|
||||
const now = opts.now ?? this.clock();
|
||||
const payload = this.acceptToken(token, "impression", now);
|
||||
|
||||
const impression: Impression = {
|
||||
type: "agentad.impression",
|
||||
version: AGENTAD_VERSION,
|
||||
impression_token: token,
|
||||
ad_id: payload.aid,
|
||||
placement_id: payload.pid,
|
||||
...(opts.consumer ? { consumer: opts.consumer } : {}),
|
||||
occurred_at: new Date(now).toISOString()
|
||||
};
|
||||
assertValid("agentad-impression", impression);
|
||||
|
||||
let charged = 0;
|
||||
if (payload.model === "cpm" || payload.model === "flat") {
|
||||
charged = this.settle(payload, "impression", now);
|
||||
} else {
|
||||
this.record("impression", payload, 0, now);
|
||||
}
|
||||
|
||||
const clickToken = mintToken(this.secret, {
|
||||
...payload,
|
||||
k: "click",
|
||||
n: nonce(),
|
||||
exp: now + this.tokenTtlMs
|
||||
});
|
||||
|
||||
return { impression, click_token: clickToken, charged };
|
||||
}
|
||||
|
||||
confirmClick(
|
||||
token: string,
|
||||
opts: { now?: number; consumer?: Consumer; action?: ClickAction } = {}
|
||||
): Click {
|
||||
const now = opts.now ?? this.clock();
|
||||
const payload = this.acceptToken(token, "click", now);
|
||||
const action: ClickAction = opts.action ?? "click";
|
||||
|
||||
const click: Click = {
|
||||
type: "agentad.click",
|
||||
version: AGENTAD_VERSION,
|
||||
click_token: token,
|
||||
ad_id: payload.aid,
|
||||
placement_id: payload.pid,
|
||||
action,
|
||||
...(opts.consumer ? { consumer: opts.consumer } : {}),
|
||||
occurred_at: new Date(now).toISOString()
|
||||
};
|
||||
assertValid("agentad-click", click);
|
||||
|
||||
const billable =
|
||||
payload.model === "cpc" || (payload.model === "cpa" && action === "convert");
|
||||
if (billable) {
|
||||
this.settle(payload, "click", now, action);
|
||||
} else {
|
||||
this.record("click", payload, 0, now, action);
|
||||
}
|
||||
|
||||
return click;
|
||||
}
|
||||
|
||||
// --- reporting ----------------------------------------------------------
|
||||
|
||||
ledger(): readonly LedgerEntry[] {
|
||||
return this.ledgerEntries;
|
||||
}
|
||||
|
||||
remaining(campaignId: string): number {
|
||||
return this.settlement.remaining(campaignId);
|
||||
}
|
||||
|
||||
earnings(publisherDid: string): number {
|
||||
return this.settlement.earnings(publisherDid);
|
||||
}
|
||||
|
||||
// --- internals ----------------------------------------------------------
|
||||
|
||||
private settle(
|
||||
payload: TokenLike,
|
||||
kind: "impression" | "click",
|
||||
now: number,
|
||||
action?: ClickAction
|
||||
): number {
|
||||
const placement = this.placements.get(payload.pid);
|
||||
const publisherDid = placement?.publisher_did ?? "unknown.publisher";
|
||||
const charged = this.settlement.charge({
|
||||
campaignId: payload.cid,
|
||||
publisherDid,
|
||||
amount: payload.charge,
|
||||
currency: payload.cur
|
||||
});
|
||||
if (charged > 0) {
|
||||
const key = `${payload.cid}:${dayKey(now)}`;
|
||||
this.dailySpend.set(key, (this.dailySpend.get(key) ?? 0) + charged);
|
||||
}
|
||||
this.record(kind, payload, charged, now, action);
|
||||
return charged;
|
||||
}
|
||||
|
||||
private record(
|
||||
kind: "impression" | "click",
|
||||
payload: TokenLike,
|
||||
amount: number,
|
||||
now: number,
|
||||
action?: ClickAction
|
||||
): void {
|
||||
this.ledgerEntries.push({
|
||||
kind,
|
||||
campaign_id: payload.cid,
|
||||
ad_id: payload.aid,
|
||||
placement_id: payload.pid,
|
||||
...(action ? { action } : {}),
|
||||
amount,
|
||||
currency: payload.cur,
|
||||
occurred_at: new Date(now).toISOString()
|
||||
});
|
||||
}
|
||||
|
||||
private acceptToken(token: string, kind: "impression" | "click", now: number): TokenLike {
|
||||
const result = verifyToken(this.secret, token, now);
|
||||
if (!result.ok || !result.payload) {
|
||||
throw new Error(`invalid ${kind} token: ${result.reason ?? "unknown"}`);
|
||||
}
|
||||
if (result.payload.k !== kind) {
|
||||
throw new Error(`expected a ${kind} token, got ${result.payload.k}`);
|
||||
}
|
||||
if (this.usedTokens.has(token)) {
|
||||
throw new Error(`${kind} token already consumed`);
|
||||
}
|
||||
this.usedTokens.add(token);
|
||||
return result.payload;
|
||||
}
|
||||
|
||||
private effectiveValue(ad: Ad): number {
|
||||
const p = ad.pricing;
|
||||
if (!p) return 0;
|
||||
switch (p.model) {
|
||||
case "cpm":
|
||||
return p.bid / 1000;
|
||||
case "flat":
|
||||
return p.bid;
|
||||
case "cpc":
|
||||
return p.bid * this.expectedCtr;
|
||||
case "cpa":
|
||||
return p.bid * this.expectedCtr * this.expectedCvr;
|
||||
default:
|
||||
return 0;
|
||||
}
|
||||
}
|
||||
|
||||
private unitCharge(ad: Ad, clearingFactor: number): number {
|
||||
const p = ad.pricing;
|
||||
if (!p) return 0;
|
||||
const base = p.model === "cpm" ? p.bid / 1000 : p.bid;
|
||||
return round(base * clearingFactor);
|
||||
}
|
||||
|
||||
private frequencyRemaining(placement: Placement, session: string, day: string): number {
|
||||
const caps = placement.frequency_cap;
|
||||
const perSession =
|
||||
caps?.max_per_session != null
|
||||
? caps.max_per_session - (this.sessionCount.get(`${placement.id}:${session}`) ?? 0)
|
||||
: Number.POSITIVE_INFINITY;
|
||||
const perDay =
|
||||
caps?.max_per_day != null
|
||||
? caps.max_per_day - (this.dayCount.get(`${placement.id}:${day}`) ?? 0)
|
||||
: Number.POSITIVE_INFINITY;
|
||||
return Math.min(perSession, perDay);
|
||||
}
|
||||
|
||||
private bumpFrequency(placement: Placement, session: string, day: string): void {
|
||||
const sKey = `${placement.id}:${session}`;
|
||||
const dKey = `${placement.id}:${day}`;
|
||||
this.sessionCount.set(sKey, (this.sessionCount.get(sKey) ?? 0) + 1);
|
||||
this.dayCount.set(dKey, (this.dayCount.get(dKey) ?? 0) + 1);
|
||||
}
|
||||
|
||||
private noFill(requestId: string, reason: NoFillReason): AdResponse {
|
||||
return {
|
||||
type: "agentad.ad_response",
|
||||
version: AGENTAD_VERSION,
|
||||
request_id: requestId,
|
||||
ads: [],
|
||||
no_fill_reason: reason
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
interface Candidate {
|
||||
ad: Ad;
|
||||
campaign: Campaign;
|
||||
ev: number;
|
||||
score: number;
|
||||
}
|
||||
|
||||
interface TokenLike {
|
||||
k: "impression" | "click";
|
||||
rid: string;
|
||||
pid: string;
|
||||
aid: string;
|
||||
cid: string;
|
||||
model: "cpm" | "cpc" | "cpa" | "flat";
|
||||
charge: number;
|
||||
cur: string;
|
||||
exp: number;
|
||||
n: string;
|
||||
}
|
||||
|
||||
// assertValid variant that returns a boolean instead of throwing.
|
||||
function assertValidSafe(kind: Parameters<typeof assertValid>[0], data: unknown): boolean {
|
||||
try {
|
||||
assertValid(kind, data);
|
||||
return true;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
function adCategory(ad: Ad): string | undefined {
|
||||
const value = ad.machine_readable?.category;
|
||||
return typeof value === "string" ? value : undefined;
|
||||
}
|
||||
|
||||
function contextKeywords(placement: Placement, request: AdRequest): Set<string> {
|
||||
const set = new Set<string>();
|
||||
for (const tag of placement.context_tags ?? []) set.add(tag.toLowerCase());
|
||||
for (const kw of request.context?.keywords ?? []) set.add(kw.toLowerCase());
|
||||
return set;
|
||||
}
|
||||
|
||||
function overlapCount(keywords: string[] | undefined, ctx: Set<string>): number {
|
||||
if (!keywords) return 0;
|
||||
let n = 0;
|
||||
for (const kw of keywords) if (ctx.has(kw.toLowerCase())) n += 1;
|
||||
return n;
|
||||
}
|
||||
|
||||
function render(ad: Ad, consumer: Consumer): string {
|
||||
if (consumer === "agent") {
|
||||
return JSON.stringify({
|
||||
sponsored: true,
|
||||
advertiser: ad.disclosure.advertiser_name ?? ad.advertiser_did,
|
||||
title: ad.title,
|
||||
url: ad.url,
|
||||
...(ad.cta ? { cta: ad.cta } : {}),
|
||||
data: ad.machine_readable ?? {}
|
||||
});
|
||||
}
|
||||
|
||||
if (ad.format === "banner" && ad.media?.ansi_art) {
|
||||
return `[${ad.disclosure.label}]\n${ad.media.ansi_art}\n${ad.title} — ${ad.url}`;
|
||||
}
|
||||
|
||||
const lines = [`[${ad.disclosure.label}] ${ad.title}`];
|
||||
if (ad.body) lines.push(ad.body);
|
||||
lines.push(ad.cta ? `${ad.cta}: ${ad.url}` : ad.url);
|
||||
return lines.join("\n");
|
||||
}
|
||||
|
||||
function isExpired(ad: Ad, now: number): boolean {
|
||||
if (!ad.expires_at) return false;
|
||||
const t = Date.parse(ad.expires_at);
|
||||
return Number.isFinite(t) && t < now;
|
||||
}
|
||||
|
||||
function dayKey(now: number): string {
|
||||
return new Date(now).toISOString().slice(0, 10);
|
||||
}
|
||||
|
||||
function clamp(value: number, min: number, max: number): number {
|
||||
return Math.min(max, Math.max(min, value));
|
||||
}
|
||||
|
||||
function round(value: number): number {
|
||||
return Math.round(value * 1e6) / 1e6;
|
||||
}
|
||||
|
||||
function nonce(): string {
|
||||
return Math.random().toString(36).slice(2, 10);
|
||||
}
|
||||
41
packages/agentad/src/index.ts
Normal file
41
packages/agentad/src/index.ts
Normal file
|
|
@ -0,0 +1,41 @@
|
|||
// @logicsrc/agentad — AgentAd Marketplace reference exchange.
|
||||
// See docs/agentad-marketplace.md (AgentBBS milestone M5) and docs/agentad.md.
|
||||
|
||||
export * from "./types.js";
|
||||
export {
|
||||
AGENTAD_VERSION,
|
||||
createAd,
|
||||
createCampaign,
|
||||
createPlacement,
|
||||
createAdRequest,
|
||||
type NewAd,
|
||||
type NewCampaign,
|
||||
type NewPlacement,
|
||||
type NewAdRequest
|
||||
} from "./builders.js";
|
||||
export {
|
||||
validate,
|
||||
assertValid,
|
||||
agentAdSchemas,
|
||||
type AgentAdSchemaKind,
|
||||
type ValidationResult
|
||||
} from "./validate.js";
|
||||
export {
|
||||
mintToken,
|
||||
verifyToken,
|
||||
type TokenKind,
|
||||
type TokenPayload,
|
||||
type VerifyResult
|
||||
} from "./tokens.js";
|
||||
export {
|
||||
InMemorySettlement,
|
||||
type SettlementProvider,
|
||||
type InMemorySettlementOptions
|
||||
} from "./settlement.js";
|
||||
export {
|
||||
AgentAdExchange,
|
||||
type AgentAdExchangeOptions,
|
||||
type RequestOptions,
|
||||
type ConfirmImpressionResult,
|
||||
type LedgerEntry
|
||||
} from "./exchange.js";
|
||||
100
packages/agentad/src/settlement.ts
Normal file
100
packages/agentad/src/settlement.ts
Normal file
|
|
@ -0,0 +1,100 @@
|
|||
// Settlement abstraction. In production this is backed by the CoinPay plugin
|
||||
// (DID balances, escrow, payouts). The in-memory implementation here is the
|
||||
// reference used by tests and local development; it enforces the one invariant
|
||||
// that matters: an advertiser can never be charged beyond what it escrowed.
|
||||
|
||||
export interface SettlementProvider {
|
||||
/** Lock `amount` of an advertiser's balance to a campaign. */
|
||||
escrow(campaignId: string, advertiserDid: string, amount: number, currency: string): void;
|
||||
/** How much escrow remains unspent for a campaign. */
|
||||
remaining(campaignId: string): number;
|
||||
/**
|
||||
* Charge the advertiser and credit the publisher (minus network fee).
|
||||
* Returns the amount actually charged (0 if escrow was exhausted).
|
||||
*/
|
||||
charge(input: {
|
||||
campaignId: string;
|
||||
publisherDid: string;
|
||||
amount: number;
|
||||
currency: string;
|
||||
}): number;
|
||||
/** Total credited to a publisher, net of fees. */
|
||||
earnings(publisherDid: string): number;
|
||||
/** Total network fee collected. */
|
||||
fees(): number;
|
||||
}
|
||||
|
||||
interface EscrowRecord {
|
||||
advertiserDid: string;
|
||||
currency: string;
|
||||
locked: number;
|
||||
spent: number;
|
||||
}
|
||||
|
||||
export interface InMemorySettlementOptions {
|
||||
/** Network take rate, 0..1. Default 0.15. */
|
||||
networkFeeRate?: number;
|
||||
}
|
||||
|
||||
export class InMemorySettlement implements SettlementProvider {
|
||||
private readonly escrows = new Map<string, EscrowRecord>();
|
||||
private readonly publisherEarnings = new Map<string, number>();
|
||||
private feePool = 0;
|
||||
private readonly feeRate: number;
|
||||
|
||||
constructor(options: InMemorySettlementOptions = {}) {
|
||||
const rate = options.networkFeeRate ?? 0.15;
|
||||
if (rate < 0 || rate >= 1) {
|
||||
throw new Error(`networkFeeRate must be in [0, 1), got ${rate}`);
|
||||
}
|
||||
this.feeRate = rate;
|
||||
}
|
||||
|
||||
escrow(campaignId: string, advertiserDid: string, amount: number, currency: string): void {
|
||||
if (amount < 0) throw new Error("escrow amount must be >= 0");
|
||||
const existing = this.escrows.get(campaignId);
|
||||
if (existing) {
|
||||
if (existing.currency !== currency) {
|
||||
throw new Error(`campaign ${campaignId} escrow currency mismatch`);
|
||||
}
|
||||
existing.locked += amount;
|
||||
return;
|
||||
}
|
||||
this.escrows.set(campaignId, { advertiserDid, currency, locked: amount, spent: 0 });
|
||||
}
|
||||
|
||||
remaining(campaignId: string): number {
|
||||
const rec = this.escrows.get(campaignId);
|
||||
if (!rec) return 0;
|
||||
return Math.max(0, rec.locked - rec.spent);
|
||||
}
|
||||
|
||||
charge(input: { campaignId: string; publisherDid: string; amount: number; currency: string }): number {
|
||||
const rec = this.escrows.get(input.campaignId);
|
||||
if (!rec || input.amount <= 0) return 0;
|
||||
if (rec.currency !== input.currency) {
|
||||
throw new Error(`campaign ${input.campaignId} charge currency mismatch`);
|
||||
}
|
||||
|
||||
const available = Math.max(0, rec.locked - rec.spent);
|
||||
const charged = Math.min(available, input.amount);
|
||||
if (charged <= 0) return 0;
|
||||
|
||||
rec.spent += charged;
|
||||
const fee = charged * this.feeRate;
|
||||
this.feePool += fee;
|
||||
this.publisherEarnings.set(
|
||||
input.publisherDid,
|
||||
(this.publisherEarnings.get(input.publisherDid) ?? 0) + (charged - fee)
|
||||
);
|
||||
return charged;
|
||||
}
|
||||
|
||||
earnings(publisherDid: string): number {
|
||||
return this.publisherEarnings.get(publisherDid) ?? 0;
|
||||
}
|
||||
|
||||
fees(): number {
|
||||
return this.feePool;
|
||||
}
|
||||
}
|
||||
49
packages/agentad/src/tokens.test.ts
Normal file
49
packages/agentad/src/tokens.test.ts
Normal file
|
|
@ -0,0 +1,49 @@
|
|||
import { describe, expect, it } from "vitest";
|
||||
import { mintToken, verifyToken, type TokenPayload } from "./tokens.js";
|
||||
|
||||
const base: TokenPayload = {
|
||||
k: "impression",
|
||||
rid: "req-1",
|
||||
pid: "plc-1",
|
||||
aid: "ad-1",
|
||||
cid: "cmp-1",
|
||||
model: "cpc",
|
||||
charge: 0.5,
|
||||
cur: "USD",
|
||||
exp: 2_000_000_000_000,
|
||||
n: "abc123"
|
||||
};
|
||||
|
||||
describe("AgentAd tracking tokens", () => {
|
||||
it("round-trips a signed payload", () => {
|
||||
const token = mintToken("s3cret", base);
|
||||
const result = verifyToken("s3cret", token, 1_000);
|
||||
expect(result.ok).toBe(true);
|
||||
expect(result.payload).toEqual(base);
|
||||
});
|
||||
|
||||
it("rejects a token signed with a different secret", () => {
|
||||
const token = mintToken("s3cret", base);
|
||||
const result = verifyToken("other", token, 1_000);
|
||||
expect(result.ok).toBe(false);
|
||||
expect(result.reason).toBe("bad_signature");
|
||||
});
|
||||
|
||||
it("rejects a tampered body", () => {
|
||||
const token = mintToken("s3cret", base);
|
||||
const [, sig] = token.split(".");
|
||||
const forged = `${Buffer.from(JSON.stringify({ ...base, charge: 9999 })).toString("base64url")}.${sig}`;
|
||||
expect(verifyToken("s3cret", forged).ok).toBe(false);
|
||||
});
|
||||
|
||||
it("rejects expired tokens", () => {
|
||||
const token = mintToken("s3cret", { ...base, exp: 500 });
|
||||
const result = verifyToken("s3cret", token, 1_000);
|
||||
expect(result.ok).toBe(false);
|
||||
expect(result.reason).toBe("expired");
|
||||
});
|
||||
|
||||
it("flags malformed tokens", () => {
|
||||
expect(verifyToken("s3cret", "not-a-token").reason).toBe("malformed");
|
||||
});
|
||||
});
|
||||
78
packages/agentad/src/tokens.ts
Normal file
78
packages/agentad/src/tokens.ts
Normal file
|
|
@ -0,0 +1,78 @@
|
|||
// Signed, single-use tracking tokens for impressions and clicks. The exchange
|
||||
// mints an impression_token when it serves an ad; confirming that impression
|
||||
// mints a click_token. Tokens are HMAC-signed so a publisher cannot forge a
|
||||
// billable event, and they carry the pricing context needed to settle.
|
||||
|
||||
import { createHmac, timingSafeEqual } from "node:crypto";
|
||||
|
||||
export type TokenKind = "impression" | "click";
|
||||
|
||||
export interface TokenPayload {
|
||||
/** Token kind. */
|
||||
k: TokenKind;
|
||||
/** request_id the token was minted for. */
|
||||
rid: string;
|
||||
/** placement id. */
|
||||
pid: string;
|
||||
/** ad id. */
|
||||
aid: string;
|
||||
/** campaign id. */
|
||||
cid: string;
|
||||
/** pricing model of the winning ad. */
|
||||
model: "cpm" | "cpc" | "cpa" | "flat";
|
||||
/** per-unit charge for the winner at the second-price clearing level. */
|
||||
charge: number;
|
||||
/** currency code. */
|
||||
cur: string;
|
||||
/** unix ms expiry. */
|
||||
exp: number;
|
||||
/** random nonce to keep tokens unique + single-use. */
|
||||
n: string;
|
||||
}
|
||||
|
||||
function b64url(input: Buffer | string): string {
|
||||
return Buffer.from(input).toString("base64url");
|
||||
}
|
||||
|
||||
function sign(secret: string, body: string): string {
|
||||
return createHmac("sha256", secret).update(body).digest("base64url");
|
||||
}
|
||||
|
||||
export function mintToken(secret: string, payload: TokenPayload): string {
|
||||
const body = b64url(JSON.stringify(payload));
|
||||
return `${body}.${sign(secret, body)}`;
|
||||
}
|
||||
|
||||
export interface VerifyResult {
|
||||
ok: boolean;
|
||||
reason?: "malformed" | "bad_signature" | "expired";
|
||||
payload?: TokenPayload;
|
||||
}
|
||||
|
||||
export function verifyToken(secret: string, token: string, now = Date.now()): VerifyResult {
|
||||
const dot = token.indexOf(".");
|
||||
if (dot <= 0) return { ok: false, reason: "malformed" };
|
||||
|
||||
const body = token.slice(0, dot);
|
||||
const sig = token.slice(dot + 1);
|
||||
const expected = sign(secret, body);
|
||||
|
||||
const a = Buffer.from(sig);
|
||||
const b = Buffer.from(expected);
|
||||
if (a.length !== b.length || !timingSafeEqual(a, b)) {
|
||||
return { ok: false, reason: "bad_signature" };
|
||||
}
|
||||
|
||||
let payload: TokenPayload;
|
||||
try {
|
||||
payload = JSON.parse(Buffer.from(body, "base64url").toString("utf8")) as TokenPayload;
|
||||
} catch {
|
||||
return { ok: false, reason: "malformed" };
|
||||
}
|
||||
|
||||
if (typeof payload.exp === "number" && payload.exp < now) {
|
||||
return { ok: false, reason: "expired", payload };
|
||||
}
|
||||
|
||||
return { ok: true, payload };
|
||||
}
|
||||
128
packages/agentad/src/types.ts
Normal file
128
packages/agentad/src/types.ts
Normal file
|
|
@ -0,0 +1,128 @@
|
|||
// TypeScript contract types mirroring the @logicsrc/schemas agentad-*.schema.json
|
||||
// documents. These are hand-maintained views of the canonical JSON Schemas; the
|
||||
// runtime source of truth is validation via ./validate.ts.
|
||||
|
||||
export type AdFormat = "text" | "markdown" | "ansi" | "banner" | "json";
|
||||
export type Surface = "cli" | "tui" | "agent" | "ci";
|
||||
export type PricingModel = "cpm" | "cpc" | "cpa" | "flat";
|
||||
export type Consumer = "human" | "agent";
|
||||
export type CampaignStatus = "draft" | "active" | "paused" | "completed";
|
||||
export type ClickAction = "click" | "open_url" | "copy_command" | "install" | "convert";
|
||||
export type NoFillReason = "no_inventory" | "frequency_capped" | "blocked_category" | "invalid_request";
|
||||
|
||||
export interface Disclosure {
|
||||
sponsored: true;
|
||||
label: string;
|
||||
advertiser_name?: string;
|
||||
}
|
||||
|
||||
export interface AdPricing {
|
||||
model: PricingModel;
|
||||
bid: number;
|
||||
currency: string;
|
||||
}
|
||||
|
||||
export interface AdTargeting {
|
||||
surfaces?: Surface[];
|
||||
keywords?: string[];
|
||||
tools?: string[];
|
||||
languages?: string[];
|
||||
exclude_keywords?: string[];
|
||||
}
|
||||
|
||||
export interface Ad {
|
||||
type: "agentad.ad";
|
||||
version: string;
|
||||
id: string;
|
||||
campaign_id?: string;
|
||||
advertiser_did: string;
|
||||
format: AdFormat;
|
||||
title: string;
|
||||
body?: string;
|
||||
url: string;
|
||||
cta?: string;
|
||||
disclosure: Disclosure;
|
||||
machine_readable?: Record<string, unknown>;
|
||||
targeting?: AdTargeting;
|
||||
media?: { ansi_art?: string; icon?: string };
|
||||
pricing?: AdPricing;
|
||||
expires_at?: string;
|
||||
}
|
||||
|
||||
export interface CampaignBudget {
|
||||
total: number;
|
||||
daily_cap?: number;
|
||||
currency: string;
|
||||
}
|
||||
|
||||
export interface Campaign {
|
||||
type: "agentad.campaign";
|
||||
version: string;
|
||||
id: string;
|
||||
advertiser_did: string;
|
||||
name: string;
|
||||
status?: CampaignStatus;
|
||||
budget: CampaignBudget;
|
||||
schedule?: { start_at?: string; end_at?: string };
|
||||
ad_ids?: string[];
|
||||
}
|
||||
|
||||
export interface Placement {
|
||||
type: "agentad.placement";
|
||||
version: string;
|
||||
id: string;
|
||||
publisher_did: string;
|
||||
surface: Surface;
|
||||
accepted_formats: AdFormat[];
|
||||
dimensions?: { max_width?: number; max_lines?: number };
|
||||
context_tags?: string[];
|
||||
frequency_cap?: { max_per_session?: number; max_per_day?: number };
|
||||
allow_categories?: string[];
|
||||
block_categories?: string[];
|
||||
}
|
||||
|
||||
export interface AdRequest {
|
||||
type: "agentad.ad_request";
|
||||
version: string;
|
||||
placement_id: string;
|
||||
publisher_did?: string;
|
||||
consumer?: Consumer;
|
||||
context?: { tool?: string; keywords?: string[]; language?: string; locale?: string };
|
||||
format_override?: AdFormat;
|
||||
count?: number;
|
||||
}
|
||||
|
||||
export interface ServedAd {
|
||||
ad: Ad;
|
||||
impression_token: string;
|
||||
rendered?: string;
|
||||
}
|
||||
|
||||
export interface AdResponse {
|
||||
type: "agentad.ad_response";
|
||||
version: string;
|
||||
request_id: string;
|
||||
ads: ServedAd[];
|
||||
no_fill_reason?: NoFillReason;
|
||||
}
|
||||
|
||||
export interface Impression {
|
||||
type: "agentad.impression";
|
||||
version: string;
|
||||
impression_token: string;
|
||||
ad_id: string;
|
||||
placement_id?: string;
|
||||
consumer?: Consumer;
|
||||
occurred_at: string;
|
||||
}
|
||||
|
||||
export interface Click {
|
||||
type: "agentad.click";
|
||||
version: string;
|
||||
click_token: string;
|
||||
ad_id: string;
|
||||
placement_id?: string;
|
||||
action?: ClickAction;
|
||||
consumer?: Consumer;
|
||||
occurred_at: string;
|
||||
}
|
||||
72
packages/agentad/src/validate.ts
Normal file
72
packages/agentad/src/validate.ts
Normal file
|
|
@ -0,0 +1,72 @@
|
|||
// Runtime validation against the canonical AgentAd JSON Schemas. Mirrors the
|
||||
// approach in @logicsrc/validators: import the schema documents directly from
|
||||
// @logicsrc/schemas and compile them with Ajv 2020. Keeping this local (rather
|
||||
// than importing the built @logicsrc/validators dist) lets the exchange and its
|
||||
// tests run straight from source with no cross-package build step.
|
||||
|
||||
import * as Ajv2020Module from "ajv/dist/2020.js";
|
||||
import * as addFormatsModule from "ajv-formats";
|
||||
import type { ErrorObject } from "ajv";
|
||||
|
||||
import adSchema from "../../schemas/schemas/agentad-ad.schema.json" with { type: "json" };
|
||||
import placementSchema from "../../schemas/schemas/agentad-placement.schema.json" with { type: "json" };
|
||||
import adRequestSchema from "../../schemas/schemas/agentad-ad-request.schema.json" with { type: "json" };
|
||||
import adResponseSchema from "../../schemas/schemas/agentad-ad-response.schema.json" with { type: "json" };
|
||||
import impressionSchema from "../../schemas/schemas/agentad-impression.schema.json" with { type: "json" };
|
||||
import clickSchema from "../../schemas/schemas/agentad-click.schema.json" with { type: "json" };
|
||||
import campaignSchema from "../../schemas/schemas/agentad-campaign.schema.json" with { type: "json" };
|
||||
|
||||
type CompiledValidator = { (data: unknown): boolean; errors?: ErrorObject[] | null };
|
||||
const Ajv2020 = (Ajv2020Module as unknown as {
|
||||
default: new (options: Record<string, unknown>) => { compile: (schema: unknown) => CompiledValidator };
|
||||
}).default;
|
||||
const addFormats = (addFormatsModule as unknown as { default: (ajv: unknown) => void }).default;
|
||||
|
||||
export const agentAdSchemas = {
|
||||
"agentad-ad": adSchema,
|
||||
"agentad-placement": placementSchema,
|
||||
"agentad-ad-request": adRequestSchema,
|
||||
"agentad-ad-response": adResponseSchema,
|
||||
"agentad-impression": impressionSchema,
|
||||
"agentad-click": clickSchema,
|
||||
"agentad-campaign": campaignSchema
|
||||
} as const;
|
||||
|
||||
export type AgentAdSchemaKind = keyof typeof agentAdSchemas;
|
||||
|
||||
export type ValidationResult =
|
||||
| { ok: true; kind: AgentAdSchemaKind; data: unknown }
|
||||
| { ok: false; kind: AgentAdSchemaKind; errors: ErrorObject[] };
|
||||
|
||||
const cache = new Map<AgentAdSchemaKind, CompiledValidator>();
|
||||
|
||||
function compiled(kind: AgentAdSchemaKind): CompiledValidator {
|
||||
const existing = cache.get(kind);
|
||||
if (existing) return existing;
|
||||
|
||||
const ajv = new Ajv2020({ allErrors: true, strict: true });
|
||||
addFormats(ajv);
|
||||
const fn = ajv.compile(agentAdSchemas[kind]);
|
||||
cache.set(kind, fn);
|
||||
return fn;
|
||||
}
|
||||
|
||||
export function validate(kind: AgentAdSchemaKind, data: unknown): ValidationResult {
|
||||
const fn = compiled(kind);
|
||||
const ok = fn(data);
|
||||
return ok
|
||||
? { ok: true, kind, data }
|
||||
: { ok: false, kind, errors: fn.errors ?? [] };
|
||||
}
|
||||
|
||||
/** Validate or throw with a readable message. Returns the value narrowed to T. */
|
||||
export function assertValid<T>(kind: AgentAdSchemaKind, data: T): T {
|
||||
const result = validate(kind, data);
|
||||
if (!result.ok) {
|
||||
const detail = result.errors
|
||||
.map((e) => `${e.instancePath || "/"} ${e.message ?? "is invalid"}`)
|
||||
.join("; ");
|
||||
throw new Error(`Invalid ${kind} document: ${detail}`);
|
||||
}
|
||||
return data;
|
||||
}
|
||||
8
packages/agentad/tsconfig.json
Normal file
8
packages/agentad/tsconfig.json
Normal file
|
|
@ -0,0 +1,8 @@
|
|||
{
|
||||
"extends": "../../tsconfig.base.json",
|
||||
"compilerOptions": {
|
||||
"rootDir": "src",
|
||||
"outDir": "dist"
|
||||
},
|
||||
"include": ["src/**/*.ts"]
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue