@affset/mcp 0.2.0 → 0.4.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -4,39 +4,57 @@ An [MCP](https://modelcontextprotocol.io) server that lets a media buyer run aff
4
4
  from a chat client — pull stats, manage campaigns/zones/team, payouts, targeting,
5
5
  sub labels, and cut underperforming zones in plain language, no dashboard.
6
6
 
7
- It's a thin wrapper over the existing affset tenant API (`Bearer` token +
8
- `X-Namespace`). One server instance serves one tenant.
7
+ The tools wrap the existing affset tenant API. Connect through the hosted
8
+ endpoint (OAuth, no API key) or run this package locally (`Bearer` token +
9
+ `X-Namespace`). One connection serves one tenant.
10
+
11
+ **Fastest way to connect — the hosted endpoint.** Add
12
+ `https://mcp.affset.com/mcp` as a remote MCP server in Claude (web or desktop),
13
+ Cursor, Claude Code, or any client that supports streamable HTTP with OAuth:
14
+ paste the URL, sign in with your affset email, keep **Read-only** (the consent
15
+ default) or grant full access. Every connection shows up on the dashboard
16
+ Integrations page and can be revoked individually. Setup guide:
17
+ [affset.com/integrations](https://affset.com/integrations).
18
+
19
+ The npm package below is the self-host path: same tool roster, runs on your
20
+ machine with an API key you manage yourself. Stdio defaults to full access
21
+ unless you set `AFFSET_READ_ONLY=true`.
9
22
 
10
23
  ## Tools
11
24
 
12
- | Tool | What it does |
13
- | ----------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
14
- | `whoami` | Show the tenant this server is bound to: namespace, API base, derived dashboard URL, and (when readable) company / timezone / custom API domain. Read-only. |
15
- | `get_stats` | Traffic stats grouped by a dimension (date, campaign, zone, country, sub1–5, advertiser, publisher, …), optionally narrowed with `advertiser_email`/`publisher_email` filters (one user, any group_by). Returns clicks, conversions, CR, payout, media cost and ROI as a table. Sub columns use the tenant's sub labels when configured. Per-user groupings and filters are owner/manager plus the matching side's manager role. |
16
- | `list_campaigns` | List campaigns (status / name filter, pagination). |
17
- | `get_campaign` | One campaign's full record — every field (untruncated offer URL, exact schedule, budgets/pacing, silent flag, payout goal type) plus its targeting rules and payout rules, in one call. |
18
- | `list_zones` | List traffic-source zones (status / name filter, pagination). |
19
- | `list_team` | List team members (email, role, manager). **Never returns API tokens.** |
20
- | `create_team_member` | Invite a team member (owner, manager, publisher, advertiser, publisher_manager, advertiser_manager). A scoped manager key can only create its own managed role, self-assigned. Returns the new API key **once** — `list_team` never shows it again. **Dry-run by default**; `confirm: true` to apply. |
21
- | `create_campaign` | Create a campaign from an advertiser email, offer URL, geo, payout and name. Defaults: CPA / rate 0, **paused**, global payout rule, ready tracking link with `source_click_id={clickid}` + sub placeholders. **Dry-run by default**; `confirm: true` to apply. |
22
- | `set_campaign_status` | **Run** or **pause** a campaign (`action: "run" \| "pause"`). **Dry-run by default**; `confirm: true` to apply. Running can hit the plan's active-campaign limit. |
23
- | `update_campaign` | Partial update (name, offer URL, status, rate, budgets, dates, …). **Dry-run by default**; `confirm: true` to apply. Prefer `set_campaign_status` for run/pause. |
24
- | `create_zone` | Create a traffic-source zone (name + optional postback/site/traffic-back URLs). Always created `active`. **Dry-run by default**; `confirm: true` to apply. |
25
- | `update_zone` | Partial update (name, status, URLs). **Dry-run by default**; `confirm: true` to apply. Pass `null` to clear a URL. |
26
- | `get_zone_url` | The `/serve` URL to paste into a network's campaign settings — rotates across the zone's **active** campaigns. Prefilled sub convention, optional `cost` macro. Warns when no active campaigns are visible. |
27
- | `get_tracking_link` | The `/track/click` link for an existing campaign + zone straight to one active campaign, with no rotation or targeting checks. Re-derives what `create_campaign` echoed on create. |
28
- | `cut_zones` | Blacklist underperforming zones on a campaign by threshold (CR / spend / ROI). **Dry-run by default**; `confirm: true` to apply. |
29
- | `list_payout_rules` | List a campaign's global + per-zone payout rules and its `payout_goal_type`. |
30
- | `set_payout_rule` | Upsert a global or zone-specific payout. **Dry-run by default**; `confirm: true` to apply. |
31
- | `delete_payout_rule` | Delete a global or zone-specific payout rule. **Dry-run by default**; `confirm: true` to apply. |
32
- | `set_payout_goal` | Set or clear `payout_goal_type` (goal-based conversions). **Dry-run by default**; `confirm: true` to apply. |
33
- | `list_targeting_types` | Catalog of targeting rule types, flagging the seeded ones `/serve` never evaluates. |
34
- | `list_targeting_rules` | List a campaign's targeting rules, flagging any that have no effect. |
35
- | `set_targeting_rule` | Upsert one targeting rule (safe merge), normalised to what `/serve` matches. **Dry-run by default**; `confirm: true` to apply. |
36
- | `remove_targeting_rule` | Remove one targeting rule by id or type+method. **Dry-run by default**; `confirm: true` to apply. |
37
- | `list_sub_labels` | List tenant display names for sub1–sub5. |
38
- | `set_sub_labels` | Set or clear sub labels (partial; `null` clears). **Dry-run by default**; `confirm: true` to apply. |
39
- | `list_conversions` | List conversion audit records (payout, spend, pixel type, payload, postback). Optional client-side filters on the current page. |
25
+ | Tool | What it does |
26
+ | ----------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
27
+ | `whoami` | Show the tenant this server is bound to: namespace, API base, derived dashboard URL, and (when readable) company / timezone / custom API domain. Read-only. |
28
+ | `get_stats` | Traffic stats grouped by a dimension (date, campaign, zone, country, sub1–5, advertiser, publisher, …), optionally narrowed with `advertiser_email`/`publisher_email` filters (one user, any group_by). Returns clicks, conversions, CR, payout, media cost and ROI as a table. `paid_only` defaults to true (same as the dashboard) so CR excludes informative conversions. Sub columns use the tenant's sub labels when configured. Per-user groupings and filters are owner/manager plus the matching side's manager role. |
29
+ | `list_campaigns` | List campaigns (status / name filter, pagination). |
30
+ | `get_campaign` | One campaign's full record — every field (untruncated offer URL, exact schedule, budgets/pacing, silent flag, payout goal type) plus its targeting rules and payout rules, in one call. |
31
+ | `list_zones` | List traffic-source zones (status / name filter, pagination, linked source). |
32
+ | `list_team` | List team members (email, role, manager). **Never returns API tokens.** |
33
+ | `create_team_member` | Invite a team member (owner, manager, publisher, advertiser, publisher_manager, advertiser_manager). A scoped manager key can only create its own managed role, self-assigned. Returns the new API key **once** — `list_team` never shows it again. **Dry-run by default**; `confirm: true` to apply. |
34
+ | `create_campaign` | Create a campaign from an advertiser email, offer URL, geo, payout and name. Defaults: CPA / rate 0, **paused**, global payout rule, and a ready tracking link (linked source template when configured; otherwise `source_click_id={clickid}` + sub placeholders). **Dry-run by default**; `confirm: true` to apply. |
35
+ | `set_campaign_status` | **Run** or **pause** a campaign (`action: "run" \| "pause"`). **Dry-run by default**; `confirm: true` to apply. Running can hit the plan's active-campaign limit. |
36
+ | `update_campaign` | Partial update (name, offer URL, status, rate, budgets, dates, …). **Dry-run by default**; `confirm: true` to apply. Prefer `set_campaign_status` for run/pause. |
37
+ | `create_zone` | Create a traffic-source zone (name + optional postback/site/traffic-back URLs, optional `traffic_source_id` link). Always created `active`. **Dry-run by default**; `confirm: true` to apply. |
38
+ | `update_zone` | Partial update (name, status, URLs, `traffic_source_id`). **Dry-run by default**; `confirm: true` to apply. Pass `null` to clear a URL or unlink the source. |
39
+ | `list_traffic_sources` | List traffic sources the networks bought from, each with the tracking/postback templates its linked zones use. API token shown only as set/none. |
40
+ | `create_traffic_source` | Create a traffic source, optionally from a network preset (`exoclick`, `trafficstars`, `propellerads`, `adsterra`, `richads`) that copies verified templates into an editable row. **Dry-run by default**; `confirm: true` to apply. |
41
+ | `update_traffic_source` | Partial update (name, templates, api_token, status). Linked zones pick the new tracking template up immediately. **Dry-run by default**; `confirm: true` to apply. |
42
+ | `list_source_bids` | The network campaigns of one traffic source with their **current bids**, read live from the network account (ExoClick, TrafficStars, RichAds — needs the source's API token): status, pricing model, bid in USD, last change that entered Affset's mutation stage. Read-only. |
43
+ | `set_source_bid` | Set one network campaign's bid in USD using its existing pricing model (for RichAds: CPM for pops, CPC for push/display). The dry run returns `expected_current_bid`; pass that value back with `confirm: true` to bind the write to the bid reviewed, so a concurrent change is refused rather than overwritten. Records the attempt in the source's bid history and reports applied only when the network echoes the new value. Raising a bid above 5× needs `allow_large_increase: true`. **Dry-run by default**. |
44
+ | `get_zone_url` | The `/serve` URL to paste into a network's campaign settings — rotates across the zone's **active** campaigns. A zone linked to a traffic source renders that source's tracking template; otherwise prefilled sub convention + optional `cost` macro. Warns when no active campaigns are visible. |
45
+ | `get_tracking_link` | The `/track/click` link for an existing campaign + zone — straight to one active campaign, with no rotation or targeting checks. Renders a linked source's template like `get_zone_url`. Re-derives what `create_campaign` echoed on create. |
46
+ | `cut_zones` | Blacklist underperforming zones on a campaign by threshold (CR / spend / ROI). **Dry-run by default**; `confirm: true` to apply. |
47
+ | `list_payout_rules` | List a campaign's global + per-zone payout rules and its `payout_goal_type`. |
48
+ | `set_payout_rule` | Upsert a global or zone-specific payout. **Dry-run by default**; `confirm: true` to apply. |
49
+ | `delete_payout_rule` | Delete a global or zone-specific payout rule. **Dry-run by default**; `confirm: true` to apply. |
50
+ | `set_payout_goal` | Set or clear `payout_goal_type` (goal-based conversions). **Dry-run by default**; `confirm: true` to apply. |
51
+ | `list_targeting_types` | Catalog of targeting rule types, flagging the seeded ones `/serve` never evaluates. |
52
+ | `list_targeting_rules` | List a campaign's targeting rules, flagging any that have no effect. |
53
+ | `set_targeting_rule` | Upsert one targeting rule (safe merge), normalised to what `/serve` matches. **Dry-run by default**; `confirm: true` to apply. |
54
+ | `remove_targeting_rule` | Remove one targeting rule by id or type+method. **Dry-run by default**; `confirm: true` to apply. |
55
+ | `list_sub_labels` | List tenant display names for sub1–sub5. |
56
+ | `set_sub_labels` | Set or clear sub labels (partial; `null` clears). **Dry-run by default**; `confirm: true` to apply. |
57
+ | `list_conversions` | List conversion audit records (payout, spend, pixel type, payload, postback). `paid_only` filters server-side; other optional filters are client-side on the current page. |
40
58
 
41
59
  ### Which URL do I give the network?
42
60
 
@@ -88,7 +106,8 @@ under `AFFSET_READ_ONLY`.
88
106
 
89
107
  ## Configuration
90
108
 
91
- All config comes from environment variables (never hard-coded):
109
+ Self-host (stdio) only hosted connections do not use these variables. All
110
+ config comes from the environment (never hard-coded):
92
111
 
93
112
  | Variable | Description |
94
113
  | --------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
@@ -103,9 +122,28 @@ See [`.env.example`](.env.example).
103
122
 
104
123
  ## Install
105
124
 
106
- Requires Node.js 22.13 or newer.
125
+ ### Hosted (fastest no install)
107
126
 
108
- ### From npm (recommended)
127
+ Add the remote server in your MCP client and approve access in the browser.
128
+ OAuth is discovered from the endpoint — do not paste an API key, and do not add
129
+ an `Authorization` header.
130
+
131
+ - **Claude (web or desktop)** — Customize → Connectors → **+** → Add custom connector →
132
+ `https://mcp.affset.com/mcp`.
133
+ - **Cursor** — Settings → MCP → Add server, transport "streamable HTTP", same URL.
134
+ - **Claude Code** — `claude mcp add --transport http affset https://mcp.affset.com/mcp`
135
+ then authenticate with `/mcp`.
136
+
137
+ You sign in with your affset email (magic link). **Read-only** is selected on
138
+ the consent screen unless you switch to **Full access**. The connection gets
139
+ its own scoped credential — your API key is never involved — and appears on
140
+ the dashboard's Integrations page, where it can be revoked at any time. Full
141
+ guide: [affset.com/integrations](https://affset.com/integrations).
142
+
143
+ The self-host paths below run the same tool roster over stdio and require
144
+ Node.js 22.13 or newer.
145
+
146
+ ### From npm (recommended for self-hosting)
109
147
 
110
148
  No clone, no build — your MCP client runs it with `npx`. For **Claude Desktop**
111
149
  (`claude_desktop_config.json`):
@@ -203,12 +241,15 @@ for `"command": "node"`, `"args": ["/absolute/path/to/affset-mcp/dist/index.js"]
203
241
  >
204
242
  > **stats for one publisher, grouped by zone** → `get_stats(group_by: "zone_id", publisher_email: "publisher@example.com")`
205
243
  >
206
- > **create a RichAds zone with postback** → `create_zone(name: "RichAds", postback_url: "https://…/{source_click_id}")` (dry-run) → confirm
244
+ > **stats including informative conversions** → `get_stats(paid_only: false)`
245
+ >
246
+ > **set up RichAds end to end** → `create_traffic_source(name: "RichAds", preset: "richads")` (dry-run) → confirm
247
+ > → `create_zone(name: "RichAds push", traffic_source_id: "…")` (dry-run) → confirm → `get_zone_url()`
207
248
  >
208
249
  > **create a campaign for offer X, advertiser buyer@example.com, geo BR, payout $2**
209
250
  > → `create_campaign(user_email: "buyer@example.com", offer_url: "https://offer.example/lp?s={click_id}", geo: ["BR"], payout: 2)` (dry-run) → confirm
210
251
  >
211
- > **what URL do I paste into RichAds?** → `get_zone_url(cost: "{cost}")`
252
+ > **what URL do I paste into RichAds?** → `get_zone_url()` — a zone linked to a traffic source gets the source's template filled in
212
253
  >
213
254
  > **give me the link for campaign 42 again** → `get_tracking_link(campaign_id: 42)`
214
255
  >
@@ -235,7 +276,9 @@ for `"command": "node"`, `"args": ["/absolute/path/to/affset-mcp/dist/index.js"]
235
276
  >
236
277
  > **show recent conversions** → `list_conversions()`
237
278
  >
238
- > **find $0 payouts (goal miss?)** → `list_conversions(zero_payout: true)`
279
+ > **hide informative conversions (goal-type misses)** → `list_conversions(paid_only: true)`
280
+ >
281
+ > **find $0 payouts (no rule / still on this page)** → `list_conversions(zero_payout: true)`
239
282
  >
240
283
  > **lookup by source click id** → `list_conversions(source_click_id: "abc123")`
241
284
 
@@ -243,11 +286,16 @@ for `"command": "node"`, `"args": ["/absolute/path/to/affset-mcp/dist/index.js"]
243
286
 
244
287
  - **`get_stats` groups by one dimension per call.** Drill-down is a sequence of
245
288
  calls, each narrowing with `campaign_ids` / `zone_ids` / `sub1..sub5` /
246
- `conversion_type` / `advertiser_email` / `publisher_email` filters. The two email
289
+ `conversion_type` / `advertiser_email` / `publisher_email` / `paid_only` filters. The two email
247
290
  filters select one user's campaigns or zones without changing `group_by`; access
248
291
  is limited to owner/manager or the corresponding scoped manager role. Filtering
249
292
  by `conversion_type` returns conversion rows only (impressions, clicks and media
250
- cost are zero).
293
+ cost are zero). `paid_only` defaults to **true** (the API default is false; this
294
+ matches the dashboard) so the conversions count and CR drop informative rows
295
+ recorded with `postback_skipped=non_goal_type` — pixel type missed the campaign's
296
+ `payout_goal_type`. Silent conversions still count; this is not a payout>0 filter.
297
+ Set `false` for the raw count. Only recent (unfolded) events are filtered;
298
+ conversions already in daily archives stay included.
251
299
  - **`spend` means `media_cost`** (your traffic cost). ROI / spend thresholds need
252
300
  cost data imported for the slice.
253
301
  - List endpoints have **no server-side name search** — `name_contains` filters the
@@ -277,9 +325,13 @@ for `"command": "node"`, `"args": ["/absolute/path/to/affset-mcp/dist/index.js"]
277
325
  while the campaign kept buying); `list_targeting_types` flags them. Use
278
326
  `unique_users` (`visits/hours`) for frequency capping.
279
327
  - **`list_conversions`** is the conversion audit trail (not aggregated stats). The API
280
- has no campaign/zone/date filters; optional filters apply to the current page only.
281
- Rows do not include campaign_id/zone_id. Publisher-side roles do not see `spend` and
282
- advertiser-side roles do not see `payout`, so `zero_payout` needs a role that can.
328
+ has no campaign/zone/date filters; `paid_only` is the one server-side filter (`true`
329
+ drops rows recorded with `postback_skipped=non_goal_type` pixel type missed the
330
+ campaign's `payout_goal_type`; silent conversions and other skip reasons still come
331
+ back — this is not a payout>0 filter). The other optional filters apply to the current
332
+ page only. Rows do not include campaign_id/zone_id. Publisher-side roles do not see
333
+ `spend` and advertiser-side roles do not see `payout`, so `zero_payout` needs a role
334
+ that can; `paid_only` does not (it keys on `postback_skipped`, not `payout`).
283
335
  - **`create_team_member`** creates the API key directly (like the dashboard's "Add Team
284
336
  Member") — it does not send an invite email. Hand the returned key to the person
285
337
  yourself. Revoking/removing a team member is not yet a tool; use the dashboard's
@@ -298,7 +350,7 @@ for `"command": "node"`, `"args": ["/absolute/path/to/affset-mcp/dist/index.js"]
298
350
  Since 0.2.0 the package doubles as a runtime-agnostic library: everything the
299
351
  stdio server registers (tools, docs resources, read-only stripping) is exposed
300
352
  as one helper that runs on any fetch-capable runtime — Node ≥22.13 or Cloudflare
301
- Workers. The hosted affset MCP gateway (`mcp.affset.com`, in progress) consumes
353
+ Workers. The hosted affset MCP gateway (`mcp.affset.com`) consumes
302
354
  exactly this surface, so the remote roster can never drift from stdio.
303
355
 
304
356
  ```ts
@@ -345,17 +397,23 @@ npm run dev # watch mode
345
397
 
346
398
  ## Security
347
399
 
348
- - No secrets in the repo; credentials come from the environment at runtime.
400
+ - **Hosted (`https://mcp.affset.com/mcp`):** OAuth via magic link. Read-only is
401
+ the consent default (mutating tools are never registered). Full access still
402
+ dry-runs mutations until `confirm: true`. Revoke from the dashboard
403
+ Integrations page. Nothing on `mcp.affset.com` / `oauth.affset.com` asks for
404
+ an API key.
405
+ - **Self-host (stdio):** no secrets in the repo; credentials come from the
406
+ environment at runtime. Create a **dedicated, least-privilege, expiring API
407
+ key** rather than reusing an owner key.
349
408
  - `AFFSET_BASE_URL` must be `https` unless the host is loopback — no cleartext API key.
350
409
  - Tenant API responses are streamed under a 5 MB hard limit; larger bodies are
351
410
  cancelled before parsing or reaching model context.
352
411
  - stdout is the JSON-RPC channel — all logs go to stderr.
353
412
  - `list_team` redacts API tokens.
354
413
  - All mutations (including creates) follow **show → confirm → apply**.
355
- - Create a **dedicated, least-privilege API key** for the MCP rather than reusing an
356
- owner key, and give it an expiry affset's RBAC roles (owner / manager /
357
- publisher / advertiser / advertiser_manager / publisher_manager) apply to MCP
358
- tool calls exactly as they do to the dashboard.
414
+ - affset's RBAC roles (owner / manager / publisher / advertiser /
415
+ advertiser_manager / publisher_manager) apply to MCP tool calls exactly as
416
+ they do to the dashboard.
359
417
  - Pin GitHub installs to a reviewed commit or tag in long-lived environments. A
360
418
  floating `main` spec can run newer repository code the next time `npx` resolves it.
361
419
 
@@ -375,10 +433,12 @@ Mitigations in place:
375
433
  - `confirm: true` on mutating tools is a **model-level** safety net, not a security
376
434
  boundary — a model that has been steered by injected content can supply
377
435
  `confirm: true` itself. The only real boundary is your MCP client's per-call tool
378
- approval and **`AFFSET_READ_ONLY`**.
379
-
380
- **Set `AFFSET_READ_ONLY=true`** for any session where you're mainly reading stats/
381
- conversions, especially with an MCP client that auto-approves tool calls. It removes
382
- every mutation tool from the server entirely not hidden behind a prompt, unavailable
383
- to call. Reserve a read-write instance (or a separate one) for sessions where you're
384
- actively managing campaigns/zones/payouts and are reviewing each confirm yourself.
436
+ approval plus **read-only mode** (hosted: consent default; stdio:
437
+ **`AFFSET_READ_ONLY=true`**).
438
+
439
+ Prefer read-only for any session where you're mainly reading stats/conversions,
440
+ especially with an MCP client that auto-approves tool calls. It removes every
441
+ mutation tool from the server entirely not hidden behind a prompt, unavailable
442
+ to call. Reserve full access (hosted) or a read-write stdio instance for sessions
443
+ where you're actively managing campaigns/zones/payouts and are reviewing each
444
+ confirm yourself.
@@ -0,0 +1,21 @@
1
+ import type { SourceBidCampaign, SourceBidChange } from "../types.js";
2
+ /** The precision accepted by the source-bid API. */
3
+ export declare const BID_DECIMALS = 6;
4
+ /** The API's own guard (docs/api.md § Set bid): increases above this multiple need allow_large_increase. */
5
+ export declare const LARGE_INCREASE_FACTOR = 5;
6
+ /** Network campaign names come from the network account — third-party text in model context. */
7
+ export declare const NETWORK_NAME_MAX = 120;
8
+ export declare function pricingModelLabel(model: string): string;
9
+ /** Preserve every decimal place that can be sent to the network. */
10
+ export declare function formatBid(bid: number | null | undefined): string;
11
+ /** Network-supplied error text that can land in the model's context. */
12
+ export declare function networkErrorText(message: string): string;
13
+ /** Match the API's optimistic-concurrency comparison, including unknown bids. */
14
+ export declare function bidsMatch(left: number | null, right: number | null): boolean;
15
+ /** "$0.20 → $0.25" with sub-cent precision (pops bids sit at $0.005). */
16
+ export declare function describeBidChange(previous: number | null | undefined, next: number): string;
17
+ export declare function isLargeIncrease(previous: number | null | undefined, next: number): boolean;
18
+ /** One-line rendering of a change row for tables and confirmations. */
19
+ export declare function describeChangeRow(change: SourceBidChange): string;
20
+ /** Markdown table of a source's network campaigns with their live bids. */
21
+ export declare function formatBidsTable(campaigns: SourceBidCampaign[]): string;
@@ -0,0 +1,84 @@
1
+ import { capUntrusted, mdCell } from "./format.js";
2
+ /** The precision accepted by the source-bid API. */
3
+ export const BID_DECIMALS = 6;
4
+ /** The API's own guard (docs/api.md § Set bid): increases above this multiple need allow_large_increase. */
5
+ export const LARGE_INCREASE_FACTOR = 5;
6
+ /** Network campaign names come from the network account — third-party text in model context. */
7
+ export const NETWORK_NAME_MAX = 120;
8
+ const NETWORK_TOKEN_MAX = 40;
9
+ const MODEL_LABEL = {
10
+ cpc: "CPC",
11
+ cpm: "CPM",
12
+ cpa: "CPA",
13
+ cpv: "CPV",
14
+ smart_cpm: "Smart CPM",
15
+ smart_cpc: "Smart CPC",
16
+ smart_bid: "Smart bid",
17
+ };
18
+ export function pricingModelLabel(model) {
19
+ if (!model)
20
+ return "—";
21
+ return MODEL_LABEL[model] ?? mdCell(capUntrusted(model, NETWORK_TOKEN_MAX));
22
+ }
23
+ /** Preserve every decimal place that can be sent to the network. */
24
+ export function formatBid(bid) {
25
+ if (bid == null || !Number.isFinite(bid))
26
+ return "—";
27
+ return `$${bid.toLocaleString("en-US", {
28
+ minimumFractionDigits: 2,
29
+ maximumFractionDigits: BID_DECIMALS,
30
+ })}`;
31
+ }
32
+ /** Network-supplied error text that can land in the model's context. */
33
+ export function networkErrorText(message) {
34
+ return mdCell(capUntrusted(message));
35
+ }
36
+ function roundBid(bid) {
37
+ const scale = 10 ** BID_DECIMALS;
38
+ return Math.round(bid * scale) / scale;
39
+ }
40
+ /** Match the API's optimistic-concurrency comparison, including unknown bids. */
41
+ export function bidsMatch(left, right) {
42
+ return left === null || right === null ? left === right : roundBid(left) === roundBid(right);
43
+ }
44
+ /** "$0.20 → $0.25" with sub-cent precision (pops bids sit at $0.005). */
45
+ export function describeBidChange(previous, next) {
46
+ return `${formatBid(previous)} → ${formatBid(next)}`;
47
+ }
48
+ export function isLargeIncrease(previous, next) {
49
+ return previous != null && previous > 0 && next > roundBid(previous * LARGE_INCREASE_FACTOR);
50
+ }
51
+ /** One-line rendering of a change row for tables and confirmations. */
52
+ export function describeChangeRow(change) {
53
+ const when = new Date(change.created_at).toISOString().replace("T", " ").slice(0, 16);
54
+ const who = change.changed_by ? ` by ${mdCell(change.changed_by)}` : "";
55
+ const outcome = change.status === "applied"
56
+ ? "applied"
57
+ : change.status === "pending"
58
+ ? `outcome pending${change.error ? ` — ${mdCell(capUntrusted(change.error, 200))}` : ""}`
59
+ : `failed${change.error ? ` — ${mdCell(capUntrusted(change.error, 200))}` : ""}`;
60
+ return `${when} UTC · ${describeBidChange(change.previous_bid, change.bid)}${who} · ${outcome}`;
61
+ }
62
+ /** Markdown table of a source's network campaigns with their live bids. */
63
+ export function formatBidsTable(campaigns) {
64
+ if (campaigns.length === 0)
65
+ return "_The network account has no campaigns._";
66
+ const lines = [
67
+ "| Campaign | Network id | Status | Model | Bid | Last change via affset |",
68
+ "|---|---|---|---|--:|---|",
69
+ ];
70
+ for (const campaign of campaigns) {
71
+ const name = campaign.name ? mdCell(capUntrusted(campaign.name, NETWORK_NAME_MAX)) : "—";
72
+ const status = campaign.status === "other" && campaign.status_label
73
+ ? mdCell(capUntrusted(campaign.status_label, NETWORK_TOKEN_MAX))
74
+ : campaign.status;
75
+ const extra = Object.entries(campaign.extra)
76
+ .map(([key, value]) => `${mdCell(capUntrusted(key, NETWORK_TOKEN_MAX))} ${formatBid(value)}`)
77
+ .join(", ");
78
+ const bid = campaign.bid === null ? "—" : `${formatBid(campaign.bid)}${extra ? ` (${extra})` : ""}`;
79
+ const last = campaign.last_change ? describeChangeRow(campaign.last_change) : "—";
80
+ lines.push(`| ${name} | \`${mdCell(campaign.network_campaign_id)}\` | ${status} | ${pricingModelLabel(campaign.pricing_model)} | ${bid} | ${last} |`);
81
+ }
82
+ return lines.join("\n");
83
+ }
84
+ //# sourceMappingURL=bids.js.map
@@ -12,12 +12,13 @@
12
12
  */
13
13
  import type { AffsetClient } from "../client.js";
14
14
  import type { Config } from "../runtimeConfig.js";
15
- import { type SubKey, type SubLabels } from "../types.js";
15
+ import { type SubKey, type SubLabels, type Zone } from "../types.js";
16
16
  /** Query parameter carrying the traffic source's click token. */
17
17
  export declare const SOURCE_CLICK_ID_PARAM = "source_click_id";
18
18
  /**
19
- * Default click-token macro. RichAds' spelling the pilot source; other networks
20
- * substitute their own (`[CLICK_ID]`, `${SUBID}`, …).
19
+ * Generic click-token placeholder for zones with no linked traffic source the
20
+ * buyer swaps in their network's macro (`[CLICK_ID]`, `${SUBID}`, …). A linked
21
+ * source replaces the whole query with its own tracking template instead.
21
22
  */
22
23
  export declare const DEFAULT_SOURCE_CLICK_ID = "{clickid}";
23
24
  export type SubValues = Partial<Record<SubKey, string>>;
@@ -39,19 +40,48 @@ export declare function fetchTenantIntegration(client: AffsetClient, config: Con
39
40
  /** `custom_api_domain` is tenant-editable free text — only use it if it parses. */
40
41
  export declare function integrationBaseUrl(customApiDomain: string | null | undefined, fallback: string): string;
41
42
  export interface LinkParams {
42
- /** Click-token macro. Omit for the RichAds default; pass "" to leave it out. */
43
+ /** Click-token macro. Omit for the generic default; pass "" to leave it out. */
43
44
  sourceClickId?: string;
44
45
  /** Explicit sub values; unset slots fall back to a label-derived placeholder. */
45
46
  subs?: SubValues;
46
47
  /** Network cost macro for `?cost=`. Omitted when absent. */
47
48
  cost?: string;
48
49
  subLabels?: SubLabels;
50
+ /**
51
+ * A linked traffic source's tracking template: the entire query string, used
52
+ * verbatim so network macro syntaxes (`{x}`, `${X}`, `##X##`, `[X]`) survive
53
+ * byte-exact. When set and non-empty, every per-field param above is ignored.
54
+ */
55
+ template?: string;
49
56
  }
50
57
  /** `/serve/{zone_id}` — the zone URL, for campaign rotation. */
51
58
  export declare function buildZoneUrl(baseUrl: string, zoneId: string, params?: LinkParams): string;
52
59
  /** `/track/click/{campaign_id}/{zone_id}` — straight to one campaign. */
53
60
  export declare function buildTrackingLink(baseUrl: string, campaignId: number | string, zoneId: string, params?: LinkParams): string;
61
+ /**
62
+ * True only when the template contains the exact query-parameter name.
63
+ * Leading `?`/`&` and per-part whitespace are ignored so a stored template
64
+ * like `" source_click_id={x}"` still counts (create/update trim, but rows
65
+ * written from the dashboard may not).
66
+ */
67
+ export declare function templateHasParam(template: string | undefined, param: string): boolean;
54
68
  /** "Creative name" -> `creative_name`; falls back to the raw sub key. */
55
69
  export declare function placeholderName(key: string, label: string | undefined): string;
56
70
  /** "sub1 = Creative, sub2 = Placement" — so the buyer can see what belongs where. */
57
71
  export declare function subLegend(subLabels: SubLabels, subs?: SubValues): string | null;
72
+ /** What the URL tools need to render a linked source's template — or explain why not. */
73
+ export interface LinkedSourceContext {
74
+ /** Query string for the URL builder, verbatim; undefined → generic assembly. */
75
+ template?: string;
76
+ /** "Traffic source" table-row value; undefined when the zone is unlinked. */
77
+ sourceLabel?: string;
78
+ /** Warnings and suggestions to append under the URL table. */
79
+ notes: string[];
80
+ }
81
+ /**
82
+ * Resolve a zone's linked traffic source into URL-building context (PRD
83
+ * traffic-sources §4.3). Explicit link params always win over the stored
84
+ * template — that override is deliberate — and a linked-but-unreadable source
85
+ * degrades to the generic assembly with a warning instead of failing the tool.
86
+ */
87
+ export declare function resolveLinkedSource(client: AffsetClient, zone: Pick<Zone, "traffic_source_id" | "traffic_source_name" | "postback_url">, hasManualParams: boolean): Promise<LinkedSourceContext>;
@@ -10,13 +10,14 @@
10
10
  * Both carry the same query convention: `source_click_id` (the network's own click
11
11
  * token, echoed back on postback) plus the five analytics sub slots.
12
12
  */
13
- import { SUB_KEYS } from "../types.js";
13
+ import { SUB_KEYS, } from "../types.js";
14
14
  import { mdCell } from "./format.js";
15
15
  /** Query parameter carrying the traffic source's click token. */
16
16
  export const SOURCE_CLICK_ID_PARAM = "source_click_id";
17
17
  /**
18
- * Default click-token macro. RichAds' spelling the pilot source; other networks
19
- * substitute their own (`[CLICK_ID]`, `${SUBID}`, …).
18
+ * Generic click-token placeholder for zones with no linked traffic source the
19
+ * buyer swaps in their network's macro (`[CLICK_ID]`, `${SUBID}`, …). A linked
20
+ * source replaces the whole query with its own tracking template instead.
20
21
  */
21
22
  export const DEFAULT_SOURCE_CLICK_ID = "{clickid}";
22
23
  /**
@@ -89,7 +90,11 @@ export function buildTrackingLink(baseUrl, campaignId, zoneId, params = {}) {
89
90
  * read as the primary parameter in the copied URL. All five sub slots are emitted as
90
91
  * a template for the buyer to fill in or delete.
91
92
  */
92
- function withQuery(base, { sourceClickId = DEFAULT_SOURCE_CLICK_ID, subs = {}, cost, subLabels = {} }) {
93
+ function withQuery(base, params) {
94
+ const template = params.template;
95
+ if (template?.trim())
96
+ return `${base}?${template}`;
97
+ const { sourceClickId = DEFAULT_SOURCE_CLICK_ID, subs = {}, cost, subLabels = {} } = params;
93
98
  const parts = [];
94
99
  const clickId = sourceClickId.trim();
95
100
  if (clickId)
@@ -103,6 +108,16 @@ function withQuery(base, { sourceClickId = DEFAULT_SOURCE_CLICK_ID, subs = {}, c
103
108
  parts.push(`cost=${costMacro}`);
104
109
  return parts.length > 0 ? `${base}?${parts.join("&")}` : base;
105
110
  }
111
+ /**
112
+ * True only when the template contains the exact query-parameter name.
113
+ * Leading `?`/`&` and per-part whitespace are ignored so a stored template
114
+ * like `" source_click_id={x}"` still counts (create/update trim, but rows
115
+ * written from the dashboard may not).
116
+ */
117
+ export function templateHasParam(template, param) {
118
+ const query = (template ?? "").trim().replace(/^[?&]+/, "");
119
+ return query.split("&").some((part) => part.trim().startsWith(`${param}=`));
120
+ }
106
121
  /** "Creative name" -> `creative_name`; falls back to the raw sub key. */
107
122
  export function placeholderName(key, label) {
108
123
  const slug = (label ?? "")
@@ -125,4 +140,69 @@ export function subLegend(subLabels, subs = {}) {
125
140
  });
126
141
  return labelled.length > 0 ? labelled.join(" · ") : null;
127
142
  }
143
+ /**
144
+ * Resolve a zone's linked traffic source into URL-building context (PRD
145
+ * traffic-sources §4.3). Explicit link params always win over the stored
146
+ * template — that override is deliberate — and a linked-but-unreadable source
147
+ * degrades to the generic assembly with a warning instead of failing the tool.
148
+ */
149
+ export async function resolveLinkedSource(client, zone, hasManualParams) {
150
+ const notes = [];
151
+ const linkedId = zone.traffic_source_id?.trim();
152
+ if (!linkedId)
153
+ return { notes };
154
+ let source = null;
155
+ try {
156
+ source = await client.get(`/api/traffic-sources/${encodeURIComponent(linkedId)}`);
157
+ }
158
+ catch {
159
+ source = null;
160
+ }
161
+ if (!source) {
162
+ const name = mdCell(zone.traffic_source_name ?? linkedId);
163
+ return {
164
+ sourceLabel: name,
165
+ notes: [
166
+ `⚠️ This zone links to traffic source **${name}**, but it could not be read — ` +
167
+ "showing generic placeholders instead of its tracking template.",
168
+ ],
169
+ };
170
+ }
171
+ const sourceLabel = source.preset
172
+ ? `${mdCell(source.name)} (preset \`${mdCell(source.preset)}\`)`
173
+ : mdCell(source.name);
174
+ if (source.status === "archived") {
175
+ notes.push(`The linked source **${sourceLabel}** is archived — its template still renders, ` +
176
+ "but consider relinking the zone if this account is no longer bought from.");
177
+ }
178
+ // Prefill suggestion only — never auto-applied: [BRACKETED] account-specific
179
+ // values have to be filled in by the buyer first.
180
+ if (!zone.postback_url && source.postback_template) {
181
+ notes.push(source.postback_template.includes("`")
182
+ ? `The linked source has a postback template for this zone's postback_url, but it contains backticks — read it with \`list_traffic_sources\`.`
183
+ : `The linked source suggests a postback URL for this zone — replace \`[BRACKETED]\` account values, then set it with \`update_zone\`: \`${source.postback_template}\``);
184
+ }
185
+ if (hasManualParams) {
186
+ notes.push(`Explicit link parameters override the tracking template of **${sourceLabel}**.`);
187
+ return { sourceLabel, notes };
188
+ }
189
+ const template = source.tracking_template ?? "";
190
+ if (!template.trim()) {
191
+ notes.push(`Traffic source **${sourceLabel}** has no tracking template — showing generic ` +
192
+ "placeholders. Add one with `update_traffic_source`.");
193
+ return { sourceLabel, notes };
194
+ }
195
+ // A backtick cannot be rendered into the fenced URL block without letting the
196
+ // stored template break out of it; no network macro needs one.
197
+ if (template.includes("`")) {
198
+ notes.push(`⚠️ The tracking template of **${sourceLabel}** contains a backtick and was not ` +
199
+ "rendered — fix the template with `update_traffic_source`.");
200
+ return { sourceLabel, notes };
201
+ }
202
+ if (!templateHasParam(template, SOURCE_CLICK_ID_PARAM)) {
203
+ notes.push(`⚠️ The source's tracking template has no \`${SOURCE_CLICK_ID_PARAM}=\` parameter — ` +
204
+ "the network's click token is not captured, so postbacks cannot echo it back.");
205
+ }
206
+ return { template, sourceLabel, notes };
207
+ }
128
208
  //# sourceMappingURL=integrationUrls.js.map
@@ -25,3 +25,8 @@ export type LinkArgs = {
25
25
  };
26
26
  /** Pull the flat sub1..sub5 args into the record the URL builders take. */
27
27
  export declare function collectSubs(args: LinkArgs): SubValues;
28
+ /**
29
+ * True when the caller passed any link knob explicitly. That is the deliberate
30
+ * override that beats a linked traffic source's stored tracking template.
31
+ */
32
+ export declare function hasExplicitLinkParams(args: LinkArgs): boolean;