From 7d62e3b66143b145a84b2ee2cdd4d3b41f9c8d30 Mon Sep 17 00:00:00 2001 From: Anthony Ettinger Date: Wed, 1 Jul 2026 10:29:18 +0000 Subject: [PATCH] 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 --- README.md | 3 +- docs/agentad-marketplace.md | 224 +++++++++++ docs/agentad.md | 6 + package-lock.json | 15 + package.json | 2 +- packages/agentad/README.md | 85 ++++ packages/agentad/package.json | 19 + packages/agentad/src/builders.test.ts | 62 +++ packages/agentad/src/builders.ts | 90 +++++ packages/agentad/src/exchange.test.ts | 338 ++++++++++++++++ packages/agentad/src/exchange.ts | 544 ++++++++++++++++++++++++++ packages/agentad/src/index.ts | 41 ++ packages/agentad/src/settlement.ts | 100 +++++ packages/agentad/src/tokens.test.ts | 49 +++ packages/agentad/src/tokens.ts | 78 ++++ packages/agentad/src/types.ts | 128 ++++++ packages/agentad/src/validate.ts | 72 ++++ packages/agentad/tsconfig.json | 8 + 18 files changed, 1862 insertions(+), 2 deletions(-) create mode 100644 docs/agentad-marketplace.md create mode 100644 packages/agentad/README.md create mode 100644 packages/agentad/package.json create mode 100644 packages/agentad/src/builders.test.ts create mode 100644 packages/agentad/src/builders.ts create mode 100644 packages/agentad/src/exchange.test.ts create mode 100644 packages/agentad/src/exchange.ts create mode 100644 packages/agentad/src/index.ts create mode 100644 packages/agentad/src/settlement.ts create mode 100644 packages/agentad/src/tokens.test.ts create mode 100644 packages/agentad/src/tokens.ts create mode 100644 packages/agentad/src/types.ts create mode 100644 packages/agentad/src/validate.ts create mode 100644 packages/agentad/tsconfig.json diff --git a/README.md b/README.md index 80cf8b0..a16fd30 100644 --- a/README.md +++ b/README.md @@ -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. diff --git a/docs/agentad-marketplace.md b/docs/agentad-marketplace.md new file mode 100644 index 0000000..a9d7054 --- /dev/null +++ b/docs/agentad-marketplace.md @@ -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. diff --git a/docs/agentad.md b/docs/agentad.md index 51562c8..63caefa 100644 --- a/docs/agentad.md +++ b/docs/agentad.md @@ -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). diff --git a/package-lock.json b/package-lock.json index afb2fe6..0471c01 100644 --- a/package-lock.json +++ b/package-lock.json @@ -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", diff --git a/package.json b/package.json index 53f288b..df5c6b1 100644 --- a/package.json +++ b/package.json @@ -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", diff --git a/packages/agentad/README.md b/packages/agentad/README.md new file mode 100644 index 0000000..04ccab5 --- /dev/null +++ b/packages/agentad/README.md @@ -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 +``` diff --git a/packages/agentad/package.json b/packages/agentad/package.json new file mode 100644 index 0000000..a7755e8 --- /dev/null +++ b/packages/agentad/package.json @@ -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" + } +} diff --git a/packages/agentad/src/builders.test.ts b/packages/agentad/src/builders.test.ts new file mode 100644 index 0000000..4c4079b --- /dev/null +++ b/packages/agentad/src/builders.test.ts @@ -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); + }); +}); diff --git a/packages/agentad/src/builders.ts b/packages/agentad/src/builders.ts new file mode 100644 index 0000000..06721e2 --- /dev/null +++ b/packages/agentad/src/builders.ts @@ -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 & { + id?: string; + version?: string; + disclosure?: Partial; +}; + +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 & { + 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 & { + 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 & { 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>(input: T): Omit { + const { version, id, disclosure, status, ...rest } = input as Record; + void version; + void id; + void disclosure; + void status; + return rest as Omit; +} diff --git a/packages/agentad/src/exchange.test.ts b/packages/agentad/src/exchange.test.ts new file mode 100644 index 0000000..92fec4c --- /dev/null +++ b/packages/agentad/src/exchange.test.ts @@ -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); + }); +}); diff --git a/packages/agentad/src/exchange.ts b/packages/agentad/src/exchange.ts new file mode 100644 index 0000000..357add7 --- /dev/null +++ b/packages/agentad/src/exchange.ts @@ -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(); + private readonly campaigns = new Map(); + private readonly placements = new Map(); + private readonly escrowed = new Set(); + private readonly reputation = new Map(); + + private readonly usedTokens = new Set(); + private readonly sessionCount = new Map(); + private readonly dayCount = new Map(); + private readonly dailySpend = new Map(); + 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[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 { + const set = new Set(); + 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): 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); +} diff --git a/packages/agentad/src/index.ts b/packages/agentad/src/index.ts new file mode 100644 index 0000000..bcd80c8 --- /dev/null +++ b/packages/agentad/src/index.ts @@ -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"; diff --git a/packages/agentad/src/settlement.ts b/packages/agentad/src/settlement.ts new file mode 100644 index 0000000..ffb0504 --- /dev/null +++ b/packages/agentad/src/settlement.ts @@ -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(); + private readonly publisherEarnings = new Map(); + 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; + } +} diff --git a/packages/agentad/src/tokens.test.ts b/packages/agentad/src/tokens.test.ts new file mode 100644 index 0000000..c42a66c --- /dev/null +++ b/packages/agentad/src/tokens.test.ts @@ -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"); + }); +}); diff --git a/packages/agentad/src/tokens.ts b/packages/agentad/src/tokens.ts new file mode 100644 index 0000000..795f5ef --- /dev/null +++ b/packages/agentad/src/tokens.ts @@ -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 }; +} diff --git a/packages/agentad/src/types.ts b/packages/agentad/src/types.ts new file mode 100644 index 0000000..32fe887 --- /dev/null +++ b/packages/agentad/src/types.ts @@ -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; + 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; +} diff --git a/packages/agentad/src/validate.ts b/packages/agentad/src/validate.ts new file mode 100644 index 0000000..b63aae1 --- /dev/null +++ b/packages/agentad/src/validate.ts @@ -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) => { 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(); + +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(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; +} diff --git a/packages/agentad/tsconfig.json b/packages/agentad/tsconfig.json new file mode 100644 index 0000000..df59da5 --- /dev/null +++ b/packages/agentad/tsconfig.json @@ -0,0 +1,8 @@ +{ + "extends": "../../tsconfig.base.json", + "compilerOptions": { + "rootDir": "src", + "outDir": "dist" + }, + "include": ["src/**/*.ts"] +}