@affset/mcp 0.1.1 → 0.3.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.
Files changed (79) hide show
  1. package/.env.example +5 -0
  2. package/README.md +191 -56
  3. package/dist/client.d.ts +43 -0
  4. package/dist/client.js +15 -2
  5. package/dist/config.d.ts +7 -0
  6. package/dist/config.js +15 -54
  7. package/dist/core.d.ts +18 -0
  8. package/dist/core.js +18 -0
  9. package/dist/docs.d.ts +39 -0
  10. package/dist/docs.js +105 -0
  11. package/dist/index.d.ts +2 -0
  12. package/dist/lib/format.d.ts +46 -0
  13. package/dist/lib/format.js +3 -0
  14. package/dist/lib/integrationUrls.d.ts +87 -0
  15. package/dist/lib/integrationUrls.js +84 -4
  16. package/dist/lib/linkArgs.d.ts +32 -0
  17. package/dist/lib/linkArgs.js +14 -3
  18. package/dist/lib/patch.d.ts +10 -0
  19. package/dist/lib/payoutRules.d.ts +38 -0
  20. package/dist/lib/readBody.d.ts +17 -0
  21. package/dist/lib/readBody.js +69 -0
  22. package/dist/lib/targeting.d.ts +51 -0
  23. package/dist/lib/time.d.ts +38 -0
  24. package/dist/lib/toolResult.d.ts +7 -0
  25. package/dist/lib/urls.d.ts +2 -0
  26. package/dist/lib/zones.d.ts +24 -0
  27. package/dist/lib/zones.js +9 -1
  28. package/dist/registerTools.d.ts +40 -0
  29. package/dist/registerTools.js +437 -0
  30. package/dist/runtimeConfig.d.ts +37 -0
  31. package/dist/runtimeConfig.js +93 -0
  32. package/dist/server.d.ts +4 -0
  33. package/dist/server.js +3 -318
  34. package/dist/tools/createCampaign.d.ts +25 -0
  35. package/dist/tools/createCampaign.js +20 -8
  36. package/dist/tools/createTeamMember.d.ts +26 -0
  37. package/dist/tools/createTrafficSource.d.ts +25 -0
  38. package/dist/tools/createTrafficSource.js +191 -0
  39. package/dist/tools/createZone.d.ts +24 -0
  40. package/dist/tools/createZone.js +13 -1
  41. package/dist/tools/cutZones.d.ts +29 -0
  42. package/dist/tools/deletePayoutRule.d.ts +16 -0
  43. package/dist/tools/getCampaign.d.ts +12 -0
  44. package/dist/tools/getCampaign.js +150 -0
  45. package/dist/tools/getStats.d.ts +42 -0
  46. package/dist/tools/getStats.js +54 -3
  47. package/dist/tools/getTrackingLink.d.ts +23 -0
  48. package/dist/tools/getTrackingLink.js +26 -9
  49. package/dist/tools/getZoneUrl.d.ts +21 -0
  50. package/dist/tools/getZoneUrl.js +30 -13
  51. package/dist/tools/listCampaigns.d.ts +23 -0
  52. package/dist/tools/listConversions.d.ts +31 -0
  53. package/dist/tools/listConversions.js +29 -10
  54. package/dist/tools/listPayoutRules.d.ts +12 -0
  55. package/dist/tools/listSubLabels.d.ts +5 -0
  56. package/dist/tools/listTargetingRules.d.ts +12 -0
  57. package/dist/tools/listTargetingTypes.d.ts +5 -0
  58. package/dist/tools/listTeam.d.ts +14 -0
  59. package/dist/tools/listTrafficSources.d.ts +17 -0
  60. package/dist/tools/listTrafficSources.js +63 -0
  61. package/dist/tools/listZones.d.ts +23 -0
  62. package/dist/tools/listZones.js +10 -5
  63. package/dist/tools/removeTargetingRule.d.ts +21 -0
  64. package/dist/tools/setCampaignStatus.d.ts +18 -0
  65. package/dist/tools/setPayoutGoal.d.ts +16 -0
  66. package/dist/tools/setPayoutRule.d.ts +18 -0
  67. package/dist/tools/setSubLabels.d.ts +22 -0
  68. package/dist/tools/setTargetingRule.d.ts +21 -0
  69. package/dist/tools/updateCampaign.d.ts +35 -0
  70. package/dist/tools/updateTrafficSource.d.ts +25 -0
  71. package/dist/tools/updateTrafficSource.js +171 -0
  72. package/dist/tools/updateZone.d.ts +27 -0
  73. package/dist/tools/updateZone.js +13 -3
  74. package/dist/tools/whoami.d.ts +6 -0
  75. package/dist/types.d.ts +257 -0
  76. package/dist/types.js +2 -0
  77. package/dist/version.d.ts +7 -0
  78. package/dist/version.js +8 -0
  79. package/package.json +15 -2
package/dist/core.js ADDED
@@ -0,0 +1,18 @@
1
+ /**
2
+ * Runtime-agnostic library surface of @affset/mcp (REMOTE-MCP-PRD.md §5.6).
3
+ *
4
+ * Everything exported here runs on any fetch-capable runtime (Node ≥22.13,
5
+ * Cloudflare Workers) — no `process.env`, no `node:` imports. The stdio
6
+ * entrypoint (`dist/index.js`, the `affset-mcp` bin) layers env-var loading on
7
+ * top of this; the remote MCP gateway imports this surface directly and
8
+ * supplies per-grant credentials instead.
9
+ *
10
+ * `loadConfig` (env-var parsing) is deliberately NOT exported: it is the
11
+ * stdio entrypoint's concern, and its signature drags Node types into
12
+ * consumers.
13
+ */
14
+ export { registerAffsetTools, } from "./registerTools.js";
15
+ export { AffsetClient, AffsetApiError } from "./client.js";
16
+ export { DOCS_FEEDS, fetchDocsFeed, DocsFetchError } from "./docs.js";
17
+ export { VERSION } from "./version.js";
18
+ //# sourceMappingURL=core.js.map
package/dist/docs.d.ts ADDED
@@ -0,0 +1,39 @@
1
+ import { type Config } from "./runtimeConfig.js";
2
+ /**
3
+ * Fetches the affset documentation feeds that back the MCP documentation
4
+ * resources. These are static files published by the marketing site
5
+ * (see lite-adserver-home/scripts/generate-docs.mjs), generated from the same
6
+ * source as the /docs page, so the resource content never drifts from the docs.
7
+ *
8
+ * Fetched at read time — the resource is always the currently published docs,
9
+ * not a snapshot pinned to this package version. Unlike the tenant API client,
10
+ * this sends NO credentials: the docs are public, and the docs origin
11
+ * (AFFSET_DOCS_URL) is deliberately not the API host that holds the API key.
12
+ */
13
+ /** One published feed: the URI path segment and how it's served. */
14
+ export interface DocsFeed {
15
+ /** Trailing path on the docs origin, e.g. "api-reference.md". */
16
+ file: string;
17
+ mimeType: string;
18
+ }
19
+ export declare const DOCS_FEEDS: {
20
+ readonly markdown: {
21
+ readonly file: "api-reference.md";
22
+ readonly mimeType: "text/markdown";
23
+ };
24
+ readonly json: {
25
+ readonly file: "api-reference.json";
26
+ readonly mimeType: "application/json";
27
+ };
28
+ };
29
+ /** Thrown when a docs feed can't be fetched or looks wrong. */
30
+ export declare class DocsFetchError extends Error {
31
+ constructor(message: string);
32
+ }
33
+ /**
34
+ * Fetch one docs feed and return its text. Throws {@link DocsFetchError} with an
35
+ * actionable message on any network error, non-2xx status, HTML SPA fallback,
36
+ * invalid body, or oversized response — the MCP layer surfaces that to the
37
+ * caller as the resource read failure.
38
+ */
39
+ export declare function fetchDocsFeed(config: Config, feed: DocsFeed): Promise<string>;
package/dist/docs.js ADDED
@@ -0,0 +1,105 @@
1
+ import { readResponseText, ResponseTooLargeError } from "./lib/readBody.js";
2
+ import { normalizeRuntimeConfig } from "./runtimeConfig.js";
3
+ export const DOCS_FEEDS = {
4
+ markdown: { file: "api-reference.md", mimeType: "text/markdown" },
5
+ json: { file: "api-reference.json", mimeType: "application/json" },
6
+ };
7
+ /** A hostile or misconfigured origin shouldn't be able to flood model context. */
8
+ const MAX_DOCS_BYTES = 2_000_000;
9
+ /** Thrown when a docs feed can't be fetched or looks wrong. */
10
+ export class DocsFetchError extends Error {
11
+ constructor(message) {
12
+ super(message);
13
+ this.name = "DocsFetchError";
14
+ }
15
+ }
16
+ async function readDocsBody(res, url) {
17
+ try {
18
+ return await readResponseText(res, MAX_DOCS_BYTES);
19
+ }
20
+ catch (err) {
21
+ if (err instanceof ResponseTooLargeError) {
22
+ throw new DocsFetchError(err.kind === "declared"
23
+ ? `Docs response from ${url} declares Content-Length ${err.size}, over the ${MAX_DOCS_BYTES}-byte limit.`
24
+ : `Docs response from ${url} exceeded the ${MAX_DOCS_BYTES}-byte limit while streaming.`);
25
+ }
26
+ throw err;
27
+ }
28
+ }
29
+ /** SPA catch-alls often return 200 HTML for missing static feeds — refuse those. */
30
+ function assertNotHtmlShell(text, contentType, url) {
31
+ const type = (contentType ?? "").toLowerCase();
32
+ if (type.includes("text/html") || type.includes("application/xhtml")) {
33
+ throw new DocsFetchError(`Docs fetch for ${url} returned HTML (${contentType ?? "unknown type"}) instead of the documentation feed. ` +
34
+ `Is the feed deployed at that origin?`);
35
+ }
36
+ const head = text.slice(0, 256).trimStart().toLowerCase();
37
+ if (head.startsWith("<!doctype html") || head.startsWith("<html")) {
38
+ throw new DocsFetchError(`Docs fetch for ${url} returned an HTML document instead of the documentation feed. ` +
39
+ `Is the feed deployed at that origin?`);
40
+ }
41
+ }
42
+ function validateFeedBody(text, feed, url) {
43
+ if (text.length === 0) {
44
+ throw new DocsFetchError(`Docs response from ${url} was empty.`);
45
+ }
46
+ if (feed.mimeType === "application/json") {
47
+ let parsed;
48
+ try {
49
+ parsed = JSON.parse(text);
50
+ }
51
+ catch (err) {
52
+ throw new DocsFetchError(`Docs response from ${url} is not valid JSON: ${err instanceof Error ? err.message : String(err)}`);
53
+ }
54
+ if (parsed === null || typeof parsed !== "object" || Array.isArray(parsed)) {
55
+ throw new DocsFetchError(`Docs JSON from ${url} must be a top-level object.`);
56
+ }
57
+ return;
58
+ }
59
+ // Markdown feed: require a heading so we don't accept arbitrary plain text.
60
+ if (!/^#\s+\S/m.test(text.slice(0, 4_096))) {
61
+ throw new DocsFetchError(`Docs Markdown from ${url} does not look like the API reference (missing a top-level heading).`);
62
+ }
63
+ }
64
+ /**
65
+ * Fetch one docs feed and return its text. Throws {@link DocsFetchError} with an
66
+ * actionable message on any network error, non-2xx status, HTML SPA fallback,
67
+ * invalid body, or oversized response — the MCP layer surfaces that to the
68
+ * caller as the resource read failure.
69
+ */
70
+ export async function fetchDocsFeed(config, feed) {
71
+ const runtimeConfig = normalizeRuntimeConfig(config);
72
+ const url = `${runtimeConfig.docsBaseUrl}/${feed.file}`;
73
+ let res;
74
+ try {
75
+ res = await fetch(url, {
76
+ // No Authorization / X-Namespace: the docs are public and this is a
77
+ // different origin from the tenant API. Accept nudges the CDN toward the
78
+ // right representation without depending on it.
79
+ headers: { Accept: feed.mimeType },
80
+ // Don't follow redirects to a different host — a misconfigured docs
81
+ // origin shouldn't silently pull content from elsewhere.
82
+ redirect: "manual",
83
+ signal: AbortSignal.timeout(runtimeConfig.requestTimeoutMs),
84
+ });
85
+ }
86
+ catch (err) {
87
+ if (err instanceof Error && err.name === "TimeoutError") {
88
+ throw new DocsFetchError(`Timed out after ${runtimeConfig.requestTimeoutMs}ms fetching docs from ${url}`);
89
+ }
90
+ throw new DocsFetchError(`Could not reach docs at ${url}: ${err instanceof Error ? err.message : String(err)}`);
91
+ }
92
+ if (res.status >= 300 && res.status < 400) {
93
+ const location = res.headers.get("location") ?? "(no Location header)";
94
+ throw new DocsFetchError(`Docs fetch for ${url} returned HTTP ${res.status} redirect to ${location}. ` +
95
+ `AFFSET_DOCS_URL must point at the origin that serves the feeds directly.`);
96
+ }
97
+ if (!res.ok) {
98
+ throw new DocsFetchError(`Docs fetch for ${url} returned HTTP ${res.status}.`);
99
+ }
100
+ const text = await readDocsBody(res, url);
101
+ assertNotHtmlShell(text, res.headers.get("content-type"), url);
102
+ validateFeedBody(text, feed, url);
103
+ return text;
104
+ }
105
+ //# sourceMappingURL=docs.js.map
@@ -0,0 +1,2 @@
1
+ #!/usr/bin/env node
2
+ export {};
@@ -0,0 +1,46 @@
1
+ import { type GroupBy, type StatRow, type SubLabels } from "../types.js";
2
+ /**
3
+ * Escape a value for a Markdown table cell. Pipes/newlines break table layout;
4
+ * backticks and brackets are escaped too since these cells often carry
5
+ * traffic-source-controlled strings (zone names, sub values) rendered straight
6
+ * into the model's context — unescaped backticks let that data break out of its
7
+ * cell and forge what reads like a fenced code block or inline instruction.
8
+ */
9
+ export declare function mdCell(value: string): string;
10
+ /**
11
+ * Truncate a value that came from outside the tenant's own dashboard — sub values,
12
+ * zone/campaign names on rows the API returned, conversion payloads — before it
13
+ * reaches the model's context. These fields round-trip through public, unauthenticated
14
+ * endpoints (a click, a conversion pixel), so nothing bounds their length or content
15
+ * on the way in; without a cap here, one long field could bury an injected
16
+ * instruction past whatever a client renders or a reviewer reads.
17
+ */
18
+ export declare function capUntrusted(value: string, max?: number): string;
19
+ /** Format a number as USD, e.g. 1234.5 -> "$1,234.50". */
20
+ export declare function money(n: number | undefined | null): string;
21
+ /**
22
+ * Format a USD amount that can be smaller than a cent. Payout rules accept down
23
+ * to $0.00001 and push/mVAS payouts really do sit there, where `money()`'s two
24
+ * decimals would render $0.005 as "$0.00" — a preview the operator would confirm
25
+ * believing it said something else. Ordinary amounts still show two decimals.
26
+ */
27
+ export declare function moneyPrecise(n: number | undefined | null): string;
28
+ /** Format a fraction as a percentage, e.g. 0.0145 -> "1.45%". */
29
+ export declare function pct(fraction: number): string;
30
+ /** Format ROI (a fraction) with sign, e.g. 0.35 -> "+35%". Null -> "—". */
31
+ export declare function roi(fraction: number | null | undefined): string;
32
+ /** Conversion rate for a row (conversions / clicks), 0 when no clicks. */
33
+ export declare function conversionRate(row: StatRow): number;
34
+ /** The human label for a row given the grouping dimension. */
35
+ export declare function rowLabel(row: StatRow, groupBy: GroupBy): string;
36
+ /**
37
+ * Column title for a grouping: for sub1..sub5 the tenant's label (from the stats
38
+ * response `sub_labels`) with the raw key appended for addressability —
39
+ * "Zone (sub1)" — since filters/group_by still take the raw key. Raw key otherwise.
40
+ */
41
+ export declare function groupHeader(groupBy: GroupBy, subLabels?: SubLabels): string;
42
+ /**
43
+ * Render grouped stats as a Markdown table with a totals row. CR is derived
44
+ * client-side; ROI comes from the API (blank until media_cost is populated).
45
+ */
46
+ export declare function formatStatsTable(rows: StatRow[], groupBy: GroupBy, subLabels?: SubLabels): string;
@@ -77,6 +77,8 @@ export function rowLabel(row, groupBy) {
77
77
  return capUntrusted(row.conversion_type || "(none)");
78
78
  case "publisher_email":
79
79
  return row.publisher_email || "(none)";
80
+ case "advertiser_email":
81
+ return row.advertiser_email || "(none)";
80
82
  default:
81
83
  // sub1..sub5 — attributed from click/pixel query params, same trust level.
82
84
  return capUntrusted(row[groupBy] || "(none)");
@@ -89,6 +91,7 @@ const HEADER_BY_GROUP = {
89
91
  country: "Country",
90
92
  conversion_type: "Conv. type",
91
93
  publisher_email: "Publisher",
94
+ advertiser_email: "Advertiser",
92
95
  sub1: "sub1",
93
96
  sub2: "sub2",
94
97
  sub3: "sub3",
@@ -0,0 +1,87 @@
1
+ /**
2
+ * The two URLs a media buyer hands to a traffic source, and the base they hang off.
3
+ *
4
+ * - **Zone URL** — `/serve/{zone_id}`. The network sends traffic here and affset
5
+ * picks a campaign out of the zone's rotation (active campaigns only).
6
+ * - **Tracking link** — `/track/click/{campaign_id}/{zone_id}`. Straight to one
7
+ * campaign, no rotation and no targeting checks. The campaign and zone still have
8
+ * to be active because the public tracking path reads the active-serving cache.
9
+ *
10
+ * Both carry the same query convention: `source_click_id` (the network's own click
11
+ * token, echoed back on postback) plus the five analytics sub slots.
12
+ */
13
+ import type { AffsetClient } from "../client.js";
14
+ import type { Config } from "../runtimeConfig.js";
15
+ import { type SubKey, type SubLabels, type Zone } from "../types.js";
16
+ /** Query parameter carrying the traffic source's click token. */
17
+ export declare const SOURCE_CLICK_ID_PARAM = "source_click_id";
18
+ /**
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.
22
+ */
23
+ export declare const DEFAULT_SOURCE_CLICK_ID = "{clickid}";
24
+ export type SubValues = Partial<Record<SubKey, string>>;
25
+ /** Everything needed to build integration URLs, from a single `/api/tenant` read. */
26
+ export interface TenantIntegration {
27
+ /** Origin the network will actually be pointed at (custom API domain when set). */
28
+ baseUrl: string;
29
+ subLabels: SubLabels;
30
+ }
31
+ /**
32
+ * Resolve the origin for URLs that leave the building. A tenant with a custom API
33
+ * domain must see that domain: these URLs get pasted into a network's campaign
34
+ * settings, so they have to be the final ones.
35
+ *
36
+ * Never fails — the links matter more than the branding, so a bad or unreadable
37
+ * setting falls back to the configured API base.
38
+ */
39
+ export declare function fetchTenantIntegration(client: AffsetClient, config: Config): Promise<TenantIntegration>;
40
+ /** `custom_api_domain` is tenant-editable free text — only use it if it parses. */
41
+ export declare function integrationBaseUrl(customApiDomain: string | null | undefined, fallback: string): string;
42
+ export interface LinkParams {
43
+ /** Click-token macro. Omit for the generic default; pass "" to leave it out. */
44
+ sourceClickId?: string;
45
+ /** Explicit sub values; unset slots fall back to a label-derived placeholder. */
46
+ subs?: SubValues;
47
+ /** Network cost macro for `?cost=`. Omitted when absent. */
48
+ cost?: string;
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;
56
+ }
57
+ /** `/serve/{zone_id}` — the zone URL, for campaign rotation. */
58
+ export declare function buildZoneUrl(baseUrl: string, zoneId: string, params?: LinkParams): string;
59
+ /** `/track/click/{campaign_id}/{zone_id}` — straight to one campaign. */
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;
68
+ /** "Creative name" -> `creative_name`; falls back to the raw sub key. */
69
+ export declare function placeholderName(key: string, label: string | undefined): string;
70
+ /** "sub1 = Creative, sub2 = Placement" — so the buyer can see what belongs where. */
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
@@ -0,0 +1,32 @@
1
+ /**
2
+ * Input shape shared by the two integration-URL tools. Both hand a URL to a traffic
3
+ * source, so both take the same knobs: which click-token macro to use, what to put
4
+ * in the five sub slots, and whether to import the network's cost macro.
5
+ */
6
+ import { z } from "zod";
7
+ import { type SubValues } from "./integrationUrls.js";
8
+ export declare const linkInputSchema: {
9
+ source_click_id: z.ZodOptional<z.ZodEffects<z.ZodString, string, string>>;
10
+ cost: z.ZodOptional<z.ZodEffects<z.ZodString, string, string>>;
11
+ sub1: z.ZodOptional<z.ZodEffects<z.ZodString, string, string>>;
12
+ sub2: z.ZodOptional<z.ZodEffects<z.ZodString, string, string>>;
13
+ sub3: z.ZodOptional<z.ZodEffects<z.ZodString, string, string>>;
14
+ sub4: z.ZodOptional<z.ZodEffects<z.ZodString, string, string>>;
15
+ sub5: z.ZodOptional<z.ZodEffects<z.ZodString, string, string>>;
16
+ };
17
+ export type LinkArgs = {
18
+ source_click_id?: string;
19
+ cost?: string;
20
+ sub1?: string;
21
+ sub2?: string;
22
+ sub3?: string;
23
+ sub4?: string;
24
+ sub5?: string;
25
+ };
26
+ /** Pull the flat sub1..sub5 args into the record the URL builders take. */
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;
@@ -27,9 +27,11 @@ const subSchema = (key) => linkValue(key)
27
27
  export const linkInputSchema = {
28
28
  source_click_id: linkValue("source_click_id")
29
29
  .optional()
30
- .describe("The traffic source's click-token macro, e.g. `{clickid}` (RichAds), `[CLICK_ID]`, " +
31
- `\`\${SUBID}\`. Default \`${DEFAULT_SOURCE_CLICK_ID}\`. This is what conversion ` +
32
- 'postbacks echo back via {source_click_id} — pass "" only to omit it deliberately.'),
30
+ .describe("The traffic source's click-token macro, e.g. `[CLICK_ID]` (RichAds), " +
31
+ `\`\${SUBID}\` (PropellerAds). Default \`${DEFAULT_SOURCE_CLICK_ID}\`. This is what ` +
32
+ 'conversion postbacks echo back via {source_click_id} — pass "" only to omit it ' +
33
+ "deliberately. Zones linked to a traffic source render the source's tracking " +
34
+ "template instead; passing any explicit link parameter overrides that template."),
33
35
  cost: linkValue("cost")
34
36
  .optional()
35
37
  .describe("The network's cost macro, e.g. `{cost}`. Adds `&cost=…` so media cost is imported " +
@@ -50,4 +52,13 @@ export function collectSubs(args) {
50
52
  }
51
53
  return subs;
52
54
  }
55
+ /**
56
+ * True when the caller passed any link knob explicitly. That is the deliberate
57
+ * override that beats a linked traffic source's stored tracking template.
58
+ */
59
+ export function hasExplicitLinkParams(args) {
60
+ return (args.source_click_id !== undefined ||
61
+ args.cost !== undefined ||
62
+ SUB_KEYS.some((key) => args[key] !== undefined));
63
+ }
53
64
  //# sourceMappingURL=linkArgs.js.map
@@ -0,0 +1,10 @@
1
+ /** One field change for dry-run / apply summaries. */
2
+ export interface FieldChange {
3
+ field: string;
4
+ from: string;
5
+ to: string;
6
+ }
7
+ /** Render a before → after markdown table. */
8
+ export declare function renderDiff(changes: FieldChange[]): string;
9
+ /** Display helper for nullable / missing values. */
10
+ export declare function displayValue(value: unknown): string;
@@ -0,0 +1,38 @@
1
+ /**
2
+ * Payout-rule helpers shared by the payout tools.
3
+ *
4
+ * The API has no update for a payout rule: the (campaign, zone) pair is unique,
5
+ * so a create over an existing scope returns 409 and changing a payout means
6
+ * delete-then-create. That leaves a window where the campaign has no rule at all,
7
+ * and a campaign with no matching rule resolves every conversion to $0 — silently,
8
+ * because a missing payout is a normal state rather than an error. `replacePayout`
9
+ * exists so that window is always either closed or reported.
10
+ */
11
+ import { type AffsetClient } from "../client.js";
12
+ import type { PayoutRule } from "../types.js";
13
+ export declare function fetchPayoutRules(client: AffsetClient, campaignId: string): Promise<PayoutRule[]>;
14
+ /** The rule governing one scope: `null` zoneId is the campaign-wide rule. */
15
+ export declare function findPayoutRule(rules: PayoutRule[], zoneId: string | null): PayoutRule | undefined;
16
+ /** Delete the rule governing one scope (`null` zoneId = the campaign-wide rule). */
17
+ export declare function deletePayoutScope(client: AffsetClient, campaignId: string, zoneId: string | null): Promise<void>;
18
+ export declare function createPayoutRule(client: AffsetClient, campaignId: string, zoneId: string | null, payout: number): Promise<PayoutRule>;
19
+ export type ReplaceResult = {
20
+ rule: PayoutRule;
21
+ }
22
+ /** Create failed; the previous payout was put back, so nothing was lost. */
23
+ | {
24
+ rolledBack: PayoutRule;
25
+ cause: unknown;
26
+ }
27
+ /** Create failed and the rollback failed too — the scope now has no rule. */
28
+ | {
29
+ lost: PayoutRule;
30
+ cause: unknown;
31
+ rollbackCause: unknown;
32
+ };
33
+ /**
34
+ * Replace the payout for one scope. On a failed create the previous rule is
35
+ * restored, so a network blip or a rejected value cannot leave the campaign
36
+ * paying $0 on every conversion.
37
+ */
38
+ export declare function replacePayout(client: AffsetClient, campaignId: string, zoneId: string | null, existing: PayoutRule, payout: number): Promise<ReplaceResult>;
@@ -0,0 +1,17 @@
1
+ /**
2
+ * Bound upstream memory use before parsing or rendering into model context.
3
+ * Runtime-agnostic (no `node:` imports) so the tenant API client and docs
4
+ * fetcher can share it on Workers.
5
+ */
6
+ export declare class ResponseTooLargeError extends Error {
7
+ readonly kind: "declared" | "streaming";
8
+ readonly limit: number;
9
+ readonly size?: number | undefined;
10
+ readonly name = "ResponseTooLargeError";
11
+ constructor(kind: "declared" | "streaming", limit: number, size?: number | undefined);
12
+ }
13
+ /**
14
+ * Read a response body as UTF-8, aborting once it exceeds `maxBytes`. Prefers
15
+ * Content-Length when present so oversized bodies never enter memory.
16
+ */
17
+ export declare function readResponseText(res: Response, maxBytes: number): Promise<string>;
@@ -0,0 +1,69 @@
1
+ /**
2
+ * Bound upstream memory use before parsing or rendering into model context.
3
+ * Runtime-agnostic (no `node:` imports) so the tenant API client and docs
4
+ * fetcher can share it on Workers.
5
+ */
6
+ export class ResponseTooLargeError extends Error {
7
+ kind;
8
+ limit;
9
+ size;
10
+ name = "ResponseTooLargeError";
11
+ constructor(kind, limit, size) {
12
+ super(kind === "declared"
13
+ ? `declared ${size} bytes (limit ${limit})`
14
+ : `exceeded ${limit} bytes while streaming`);
15
+ this.kind = kind;
16
+ this.limit = limit;
17
+ this.size = size;
18
+ }
19
+ }
20
+ /**
21
+ * Read a response body as UTF-8, aborting once it exceeds `maxBytes`. Prefers
22
+ * Content-Length when present so oversized bodies never enter memory.
23
+ */
24
+ export async function readResponseText(res, maxBytes) {
25
+ const contentLength = res.headers.get("content-length");
26
+ if (contentLength !== null) {
27
+ const declared = Number(contentLength);
28
+ if (Number.isFinite(declared) && declared > maxBytes) {
29
+ if (res.body)
30
+ await res.body.cancel().catch(() => undefined);
31
+ throw new ResponseTooLargeError("declared", maxBytes, declared);
32
+ }
33
+ }
34
+ if (!res.body)
35
+ return "";
36
+ const reader = res.body.getReader();
37
+ const chunks = [];
38
+ let total = 0;
39
+ try {
40
+ for (;;) {
41
+ const { done, value } = await reader.read();
42
+ if (done)
43
+ break;
44
+ total += value.byteLength;
45
+ if (total > maxBytes) {
46
+ await reader.cancel().catch(() => undefined);
47
+ throw new ResponseTooLargeError("streaming", maxBytes, total);
48
+ }
49
+ chunks.push(value);
50
+ }
51
+ }
52
+ finally {
53
+ try {
54
+ reader.releaseLock();
55
+ }
56
+ catch {
57
+ // cancel() already released the lock; a throw here would mask the
58
+ // size-limit error the caller needs to surface.
59
+ }
60
+ }
61
+ const body = new Uint8Array(total);
62
+ let offset = 0;
63
+ for (const chunk of chunks) {
64
+ body.set(chunk, offset);
65
+ offset += chunk.byteLength;
66
+ }
67
+ return new TextDecoder().decode(body);
68
+ }
69
+ //# sourceMappingURL=readBody.js.map