@adport/core 0.1.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/LICENSE +201 -0
- package/dist/index.d.ts +494 -0
- package/dist/index.js +1151 -0
- package/dist/index.js.map +1 -0
- package/package.json +34 -0
package/dist/index.js
ADDED
|
@@ -0,0 +1,1151 @@
|
|
|
1
|
+
// src/errors.ts
|
|
2
|
+
var AdportError = class extends Error {
|
|
3
|
+
constructor(code, message, details) {
|
|
4
|
+
super(message);
|
|
5
|
+
this.code = code;
|
|
6
|
+
this.details = details;
|
|
7
|
+
this.name = "AdportError";
|
|
8
|
+
}
|
|
9
|
+
code;
|
|
10
|
+
details;
|
|
11
|
+
toJSON() {
|
|
12
|
+
return { error: this.code, message: this.message, details: this.details };
|
|
13
|
+
}
|
|
14
|
+
};
|
|
15
|
+
|
|
16
|
+
// src/paths.ts
|
|
17
|
+
import os from "os";
|
|
18
|
+
import path from "path";
|
|
19
|
+
function adportHome() {
|
|
20
|
+
return process.env.ADPORT_HOME ?? path.join(os.homedir(), ".config", "adport");
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
// src/model.ts
|
|
24
|
+
var METRICS = [
|
|
25
|
+
"spend",
|
|
26
|
+
"impressions",
|
|
27
|
+
"clicks",
|
|
28
|
+
"conversions",
|
|
29
|
+
"conversion_value",
|
|
30
|
+
"ctr",
|
|
31
|
+
"cpc",
|
|
32
|
+
"cpm",
|
|
33
|
+
"cpa",
|
|
34
|
+
"roas"
|
|
35
|
+
];
|
|
36
|
+
var ENTITY_LEVELS = ["account", "campaign", "ad_group", "ad"];
|
|
37
|
+
var DATE_PRESETS = ["today", "yesterday", "last_7_days", "last_30_days", "this_month"];
|
|
38
|
+
function iso(d) {
|
|
39
|
+
return d.toISOString().slice(0, 10);
|
|
40
|
+
}
|
|
41
|
+
function resolveDateRange(range, now = /* @__PURE__ */ new Date()) {
|
|
42
|
+
if (typeof range !== "string") return range;
|
|
43
|
+
const today = new Date(Date.UTC(now.getUTCFullYear(), now.getUTCMonth(), now.getUTCDate()));
|
|
44
|
+
const daysAgo = (n) => new Date(today.getTime() - n * 864e5);
|
|
45
|
+
switch (range) {
|
|
46
|
+
case "today":
|
|
47
|
+
return { start: iso(today), end: iso(today) };
|
|
48
|
+
case "yesterday":
|
|
49
|
+
return { start: iso(daysAgo(1)), end: iso(daysAgo(1)) };
|
|
50
|
+
case "last_7_days":
|
|
51
|
+
return { start: iso(daysAgo(7)), end: iso(daysAgo(1)) };
|
|
52
|
+
case "last_30_days":
|
|
53
|
+
return { start: iso(daysAgo(30)), end: iso(daysAgo(1)) };
|
|
54
|
+
case "this_month":
|
|
55
|
+
return { start: iso(new Date(Date.UTC(now.getUTCFullYear(), now.getUTCMonth(), 1))), end: iso(today) };
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
function rangeDayCount(range) {
|
|
59
|
+
const start = Date.parse(`${range.start}T00:00:00Z`);
|
|
60
|
+
const end = Date.parse(`${range.end}T00:00:00Z`);
|
|
61
|
+
return Math.max(1, Math.round((end - start) / 864e5) + 1);
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
// src/provider.ts
|
|
65
|
+
var ProviderRegistry = class {
|
|
66
|
+
providers = /* @__PURE__ */ new Map();
|
|
67
|
+
register(provider) {
|
|
68
|
+
this.providers.set(provider.id, provider);
|
|
69
|
+
}
|
|
70
|
+
get(id) {
|
|
71
|
+
const provider = this.providers.get(id);
|
|
72
|
+
if (!provider) {
|
|
73
|
+
throw new AdportError(
|
|
74
|
+
"NOT_CONNECTED",
|
|
75
|
+
`Provider "${id}" is not connected. Run \`adport connect ${id}\` first.`
|
|
76
|
+
);
|
|
77
|
+
}
|
|
78
|
+
return provider;
|
|
79
|
+
}
|
|
80
|
+
list() {
|
|
81
|
+
return [...this.providers.values()];
|
|
82
|
+
}
|
|
83
|
+
};
|
|
84
|
+
|
|
85
|
+
// src/audit/packs/core-performance.ts
|
|
86
|
+
function isActive(status) {
|
|
87
|
+
return !/(PAUS|DISABLE|REMOV|DELET|CLOS|ARCHIV)/i.test(status ?? "ENABLED");
|
|
88
|
+
}
|
|
89
|
+
var zeroConversionSpend = {
|
|
90
|
+
id: "zero-conversion-spend",
|
|
91
|
+
title: "Active campaign spending with zero conversions",
|
|
92
|
+
description: "Flags campaigns that spent above the threshold over the range without a single tracked conversion.",
|
|
93
|
+
evaluate(ctx) {
|
|
94
|
+
const minSpend = ctx.config.zero_conversion_min_spend;
|
|
95
|
+
return ctx.rows.filter((row) => isActive(row.entity.status)).filter((row) => (row.metrics.spend ?? 0) >= minSpend && (row.metrics.conversions ?? 0) === 0).map((row) => ({
|
|
96
|
+
entity: row.entity,
|
|
97
|
+
severity: (row.metrics.spend ?? 0) >= minSpend * 3 ? "critical" : "warn",
|
|
98
|
+
title: `"${row.entity.name}" spent ${row.metrics.spend} with 0 conversions`,
|
|
99
|
+
detail: `Campaign ${row.entity.id} spent ${row.metrics.spend} between ${ctx.range.start} and ${ctx.range.end} without any tracked conversion. Either conversion tracking is broken or the spend is wasted.`,
|
|
100
|
+
recommendation: "Verify conversion tracking first; if tracking is correct, pause the campaign and rework targeting/creative.",
|
|
101
|
+
proposedAction: ctx.actions.pauseCampaign?.(ctx.accountId, row.entity.id),
|
|
102
|
+
metrics: row.metrics
|
|
103
|
+
}));
|
|
104
|
+
}
|
|
105
|
+
};
|
|
106
|
+
var lowCtr = {
|
|
107
|
+
id: "low-ctr",
|
|
108
|
+
title: "High impressions with very low CTR",
|
|
109
|
+
description: "Flags campaigns whose creative/targeting is not resonating (lots of impressions, few clicks).",
|
|
110
|
+
evaluate(ctx) {
|
|
111
|
+
const minImpressions = ctx.config.low_ctr_min_impressions;
|
|
112
|
+
const threshold = ctx.config.low_ctr_threshold_pct;
|
|
113
|
+
return ctx.rows.filter((row) => isActive(row.entity.status)).filter((row) => (row.metrics.impressions ?? 0) >= minImpressions && (row.metrics.ctr ?? 100) < threshold).map((row) => ({
|
|
114
|
+
entity: row.entity,
|
|
115
|
+
severity: "warn",
|
|
116
|
+
title: `"${row.entity.name}" CTR ${row.metrics.ctr}% over ${row.metrics.impressions} impressions`,
|
|
117
|
+
detail: `CTR is below ${threshold}% despite meaningful reach \u2014 the ad or audience likely needs work.`,
|
|
118
|
+
recommendation: "Review creative and audience targeting; test new headlines/assets before touching budgets.",
|
|
119
|
+
metrics: row.metrics
|
|
120
|
+
}));
|
|
121
|
+
}
|
|
122
|
+
};
|
|
123
|
+
var cpaOutlier = {
|
|
124
|
+
id: "cpa-outlier",
|
|
125
|
+
title: "Campaign CPA far above account median",
|
|
126
|
+
description: "Flags converting campaigns whose cost per conversion is a large multiple of the account median.",
|
|
127
|
+
evaluate(ctx) {
|
|
128
|
+
const multiplier = ctx.config.cpa_outlier_multiplier;
|
|
129
|
+
const converting = ctx.rows.filter((row) => (row.metrics.conversions ?? 0) > 0 && (row.metrics.cpa ?? 0) > 0);
|
|
130
|
+
if (converting.length < 3) return [];
|
|
131
|
+
const cpas = converting.map((row) => row.metrics.cpa).sort((a, b) => a - b);
|
|
132
|
+
const median = cpas[Math.floor(cpas.length / 2)];
|
|
133
|
+
return converting.filter((row) => row.metrics.cpa > median * multiplier).map((row) => ({
|
|
134
|
+
entity: row.entity,
|
|
135
|
+
severity: "warn",
|
|
136
|
+
title: `"${row.entity.name}" CPA ${row.metrics.cpa} vs account median ${median}`,
|
|
137
|
+
detail: `Cost per conversion is more than ${multiplier}\xD7 the account median over the range.`,
|
|
138
|
+
recommendation: "Shift budget toward lower-CPA campaigns, or tighten targeting/bids here.",
|
|
139
|
+
metrics: row.metrics
|
|
140
|
+
}));
|
|
141
|
+
}
|
|
142
|
+
};
|
|
143
|
+
var negativeRoas = {
|
|
144
|
+
id: "negative-roas",
|
|
145
|
+
title: "Campaign returning less than it spends",
|
|
146
|
+
description: "Flags campaigns with tracked revenue whose ROAS is below break-even.",
|
|
147
|
+
evaluate(ctx) {
|
|
148
|
+
const minSpend = ctx.config.roas_min_spend;
|
|
149
|
+
return ctx.rows.filter((row) => isActive(row.entity.status)).filter(
|
|
150
|
+
(row) => (row.metrics.spend ?? 0) >= minSpend && (row.metrics.conversion_value ?? 0) > 0 && (row.metrics.roas ?? 0) < 1
|
|
151
|
+
).map((row) => ({
|
|
152
|
+
entity: row.entity,
|
|
153
|
+
severity: "warn",
|
|
154
|
+
title: `"${row.entity.name}" ROAS ${row.metrics.roas} (below break-even)`,
|
|
155
|
+
detail: `Spent ${row.metrics.spend} for ${row.metrics.conversion_value} in tracked revenue over the range.`,
|
|
156
|
+
recommendation: "Check margins and attribution windows; consider bid/budget reduction or audience changes.",
|
|
157
|
+
metrics: row.metrics
|
|
158
|
+
}));
|
|
159
|
+
}
|
|
160
|
+
};
|
|
161
|
+
var corePerformancePack = {
|
|
162
|
+
name: "core-performance",
|
|
163
|
+
version: "0.1.0",
|
|
164
|
+
rules: [zeroConversionSpend, lowCtr, cpaOutlier, negativeRoas],
|
|
165
|
+
defaults: {
|
|
166
|
+
zero_conversion_min_spend: 50,
|
|
167
|
+
low_ctr_min_impressions: 5e3,
|
|
168
|
+
low_ctr_threshold_pct: 0.5,
|
|
169
|
+
cpa_outlier_multiplier: 2.5,
|
|
170
|
+
roas_min_spend: 50
|
|
171
|
+
}
|
|
172
|
+
};
|
|
173
|
+
|
|
174
|
+
// src/audit/store.ts
|
|
175
|
+
import { promises as fs } from "fs";
|
|
176
|
+
import path2 from "path";
|
|
177
|
+
var FindingsStore = class {
|
|
178
|
+
constructor(dir = path2.join(adportHome(), "findings")) {
|
|
179
|
+
this.dir = dir;
|
|
180
|
+
}
|
|
181
|
+
dir;
|
|
182
|
+
file(id) {
|
|
183
|
+
return path2.join(this.dir, `${id.replace(/[^a-zA-Z0-9_.:-]/g, "_")}.json`);
|
|
184
|
+
}
|
|
185
|
+
async list(filter = {}) {
|
|
186
|
+
let names;
|
|
187
|
+
try {
|
|
188
|
+
names = await fs.readdir(this.dir);
|
|
189
|
+
} catch {
|
|
190
|
+
return [];
|
|
191
|
+
}
|
|
192
|
+
const findings = [];
|
|
193
|
+
for (const name of names) {
|
|
194
|
+
if (!name.endsWith(".json")) continue;
|
|
195
|
+
const finding = JSON.parse(await fs.readFile(path2.join(this.dir, name), "utf8"));
|
|
196
|
+
if (filter.status && finding.status !== filter.status) continue;
|
|
197
|
+
if (filter.provider && finding.provider !== filter.provider) continue;
|
|
198
|
+
findings.push(finding);
|
|
199
|
+
}
|
|
200
|
+
const order = { critical: 0, warn: 1, info: 2 };
|
|
201
|
+
return findings.sort((a, b) => (order[a.severity] ?? 3) - (order[b.severity] ?? 3));
|
|
202
|
+
}
|
|
203
|
+
async get(id) {
|
|
204
|
+
try {
|
|
205
|
+
return JSON.parse(await fs.readFile(this.file(id), "utf8"));
|
|
206
|
+
} catch (err) {
|
|
207
|
+
if (err.code === "ENOENT") return void 0;
|
|
208
|
+
throw err;
|
|
209
|
+
}
|
|
210
|
+
}
|
|
211
|
+
async save(finding) {
|
|
212
|
+
await fs.mkdir(this.dir, { recursive: true, mode: 448 });
|
|
213
|
+
await fs.writeFile(this.file(finding.id), JSON.stringify(finding, null, 2), { mode: 384 });
|
|
214
|
+
}
|
|
215
|
+
async setStatus(id, status) {
|
|
216
|
+
const finding = await this.get(id);
|
|
217
|
+
if (!finding) throw new Error(`Finding not found: ${id}`);
|
|
218
|
+
const updated = { ...finding, status, updatedAt: (/* @__PURE__ */ new Date()).toISOString() };
|
|
219
|
+
await this.save(updated);
|
|
220
|
+
return updated;
|
|
221
|
+
}
|
|
222
|
+
};
|
|
223
|
+
|
|
224
|
+
// src/audit/runner.ts
|
|
225
|
+
var AuditRunner = class {
|
|
226
|
+
constructor(providers, store = new FindingsStore()) {
|
|
227
|
+
this.providers = providers;
|
|
228
|
+
this.store = store;
|
|
229
|
+
}
|
|
230
|
+
providers;
|
|
231
|
+
store;
|
|
232
|
+
async run(options = {}) {
|
|
233
|
+
const range = resolveDateRange(options.dateRange ?? "last_30_days");
|
|
234
|
+
const packs = options.packs ?? [corePerformancePack];
|
|
235
|
+
const providers = options.provider ? [this.providers.get(options.provider)] : this.providers.list();
|
|
236
|
+
const findings = [];
|
|
237
|
+
let evaluatedAccounts = 0;
|
|
238
|
+
for (const provider of providers) {
|
|
239
|
+
const report = await provider.report({
|
|
240
|
+
accountIds: options.accountIds,
|
|
241
|
+
level: "campaign",
|
|
242
|
+
metrics: ["spend", "impressions", "clicks", "conversions", "conversion_value", "ctr", "cpc", "cpa", "roas"],
|
|
243
|
+
dateRange: range,
|
|
244
|
+
limit: 1e3
|
|
245
|
+
});
|
|
246
|
+
const byAccount = /* @__PURE__ */ new Map();
|
|
247
|
+
for (const row of report.rows) {
|
|
248
|
+
const rows = byAccount.get(row.accountId) ?? [];
|
|
249
|
+
rows.push(row);
|
|
250
|
+
byAccount.set(row.accountId, rows);
|
|
251
|
+
}
|
|
252
|
+
const actions = provider.standardActions?.() ?? {};
|
|
253
|
+
for (const [accountId, rows] of byAccount) {
|
|
254
|
+
evaluatedAccounts += 1;
|
|
255
|
+
for (const pack of packs) {
|
|
256
|
+
const config = { ...pack.defaults, ...options.configOverrides };
|
|
257
|
+
for (const rule of pack.rules) {
|
|
258
|
+
for (const ruleFinding of rule.evaluate({ provider: provider.id, accountId, rows, range, actions, config })) {
|
|
259
|
+
const id = `${rule.id}:${provider.id}:${accountId}:${ruleFinding.entity.id}`;
|
|
260
|
+
const existing = await this.store.get(id);
|
|
261
|
+
if (existing && existing.status !== "open") continue;
|
|
262
|
+
const now = (/* @__PURE__ */ new Date()).toISOString();
|
|
263
|
+
const finding = {
|
|
264
|
+
id,
|
|
265
|
+
ruleId: rule.id,
|
|
266
|
+
severity: ruleFinding.severity,
|
|
267
|
+
provider: provider.id,
|
|
268
|
+
accountId,
|
|
269
|
+
entity: ruleFinding.entity,
|
|
270
|
+
title: ruleFinding.title,
|
|
271
|
+
detail: ruleFinding.detail,
|
|
272
|
+
recommendation: ruleFinding.recommendation,
|
|
273
|
+
proposedAction: ruleFinding.proposedAction,
|
|
274
|
+
metrics: ruleFinding.metrics,
|
|
275
|
+
dateRange: range,
|
|
276
|
+
status: "open",
|
|
277
|
+
createdAt: existing?.createdAt ?? now,
|
|
278
|
+
updatedAt: now
|
|
279
|
+
};
|
|
280
|
+
await this.store.save(finding);
|
|
281
|
+
findings.push(finding);
|
|
282
|
+
}
|
|
283
|
+
}
|
|
284
|
+
}
|
|
285
|
+
}
|
|
286
|
+
}
|
|
287
|
+
return {
|
|
288
|
+
findings,
|
|
289
|
+
counts: {
|
|
290
|
+
critical: findings.filter((f) => f.severity === "critical").length,
|
|
291
|
+
warn: findings.filter((f) => f.severity === "warn").length,
|
|
292
|
+
info: findings.filter((f) => f.severity === "info").length
|
|
293
|
+
},
|
|
294
|
+
evaluatedAccounts,
|
|
295
|
+
range
|
|
296
|
+
};
|
|
297
|
+
}
|
|
298
|
+
};
|
|
299
|
+
|
|
300
|
+
// src/audit/tools.ts
|
|
301
|
+
import { z } from "zod";
|
|
302
|
+
|
|
303
|
+
// src/tools/registry.ts
|
|
304
|
+
function defineTool(def) {
|
|
305
|
+
return {
|
|
306
|
+
...def,
|
|
307
|
+
annotations: def.annotations ?? { readOnly: false }
|
|
308
|
+
};
|
|
309
|
+
}
|
|
310
|
+
var ToolRegistry = class {
|
|
311
|
+
tools = /* @__PURE__ */ new Map();
|
|
312
|
+
register(tools) {
|
|
313
|
+
for (const tool of Array.isArray(tools) ? tools : [tools]) {
|
|
314
|
+
if (this.tools.has(tool.name)) {
|
|
315
|
+
throw new Error(`Tool "${tool.name}" is already registered`);
|
|
316
|
+
}
|
|
317
|
+
this.tools.set(tool.name, tool);
|
|
318
|
+
}
|
|
319
|
+
}
|
|
320
|
+
list() {
|
|
321
|
+
return [...this.tools.values()];
|
|
322
|
+
}
|
|
323
|
+
get(name) {
|
|
324
|
+
const tool = this.tools.get(name);
|
|
325
|
+
if (!tool) throw new AdportError("UNKNOWN_TOOL", `Unknown tool: ${name}`);
|
|
326
|
+
return tool;
|
|
327
|
+
}
|
|
328
|
+
async call(name, rawInput, ctx) {
|
|
329
|
+
const tool = this.get(name);
|
|
330
|
+
const parsed = tool.input.safeParse(rawInput ?? {});
|
|
331
|
+
if (!parsed.success) {
|
|
332
|
+
throw new AdportError("INVALID_INPUT", `Invalid input for ${name}`, parsed.error.issues);
|
|
333
|
+
}
|
|
334
|
+
return tool.handler(parsed.data, ctx);
|
|
335
|
+
}
|
|
336
|
+
};
|
|
337
|
+
|
|
338
|
+
// src/audit/tools.ts
|
|
339
|
+
var dateRangeSchema = z.union([
|
|
340
|
+
z.enum(DATE_PRESETS),
|
|
341
|
+
z.object({
|
|
342
|
+
start: z.string().regex(/^\d{4}-\d{2}-\d{2}$/),
|
|
343
|
+
end: z.string().regex(/^\d{4}-\d{2}-\d{2}$/)
|
|
344
|
+
})
|
|
345
|
+
]);
|
|
346
|
+
function auditTools() {
|
|
347
|
+
return [
|
|
348
|
+
defineTool({
|
|
349
|
+
name: "audit_run",
|
|
350
|
+
namespace: "audit",
|
|
351
|
+
description: "Run the cross-platform audit rule packs over connected accounts (campaign level). Returns structured findings with recommendations; some carry a ready-to-apply proposed action. Reads ad data only \u2014 never mutates anything.",
|
|
352
|
+
input: z.object({
|
|
353
|
+
provider: z.string().optional(),
|
|
354
|
+
account_ids: z.array(z.string()).optional(),
|
|
355
|
+
date_range: dateRangeSchema.default("last_30_days")
|
|
356
|
+
}),
|
|
357
|
+
annotations: { readOnly: true },
|
|
358
|
+
async handler(input, ctx) {
|
|
359
|
+
const runner = new AuditRunner(ctx.providers);
|
|
360
|
+
const result = await runner.run({
|
|
361
|
+
provider: input.provider,
|
|
362
|
+
accountIds: input.account_ids,
|
|
363
|
+
dateRange: input.date_range
|
|
364
|
+
});
|
|
365
|
+
return result;
|
|
366
|
+
}
|
|
367
|
+
}),
|
|
368
|
+
defineTool({
|
|
369
|
+
name: "recommendations_list",
|
|
370
|
+
namespace: "audit",
|
|
371
|
+
description: "List persisted audit findings/recommendations (default: open ones), most severe first.",
|
|
372
|
+
input: z.object({
|
|
373
|
+
status: z.enum(["open", "dismissed", "applied"]).default("open"),
|
|
374
|
+
provider: z.string().optional()
|
|
375
|
+
}),
|
|
376
|
+
annotations: { readOnly: true },
|
|
377
|
+
async handler(input) {
|
|
378
|
+
const findings = await new FindingsStore().list(input);
|
|
379
|
+
return { findings, count: findings.length };
|
|
380
|
+
}
|
|
381
|
+
}),
|
|
382
|
+
defineTool({
|
|
383
|
+
name: "recommendation_dismiss",
|
|
384
|
+
namespace: "audit",
|
|
385
|
+
description: "Dismiss a finding (it will not be re-opened by future audit runs).",
|
|
386
|
+
input: z.object({ finding_id: z.string() }),
|
|
387
|
+
annotations: { readOnly: false },
|
|
388
|
+
async handler(input) {
|
|
389
|
+
const finding = await new FindingsStore().setStatus(input.finding_id, "dismissed");
|
|
390
|
+
return { finding };
|
|
391
|
+
}
|
|
392
|
+
}),
|
|
393
|
+
defineTool({
|
|
394
|
+
name: "recommendation_apply",
|
|
395
|
+
namespace: "audit",
|
|
396
|
+
description: "Execute a finding's proposed action through the normal two-step write flow: first call returns the dry-run preview and pending_operation_id; second call (with the id) applies and marks the finding applied.",
|
|
397
|
+
input: z.object({
|
|
398
|
+
finding_id: z.string(),
|
|
399
|
+
pending_operation_id: z.string().optional()
|
|
400
|
+
}),
|
|
401
|
+
annotations: { readOnly: false },
|
|
402
|
+
async handler(input, ctx) {
|
|
403
|
+
if (!ctx.registry) {
|
|
404
|
+
throw new AdportError("PROVIDER_ERROR", "recommendation_apply requires a tool registry in context");
|
|
405
|
+
}
|
|
406
|
+
const store = new FindingsStore();
|
|
407
|
+
const finding = await store.get(input.finding_id);
|
|
408
|
+
if (!finding) throw new AdportError("INVALID_INPUT", `Finding not found: ${input.finding_id}`);
|
|
409
|
+
if (finding.status !== "open") {
|
|
410
|
+
throw new AdportError("INVALID_INPUT", `Finding ${input.finding_id} is ${finding.status}, not open`);
|
|
411
|
+
}
|
|
412
|
+
if (!finding.proposedAction) {
|
|
413
|
+
throw new AdportError(
|
|
414
|
+
"INVALID_INPUT",
|
|
415
|
+
`Finding ${input.finding_id} has no proposed action \u2014 it needs human judgment (${finding.recommendation})`
|
|
416
|
+
);
|
|
417
|
+
}
|
|
418
|
+
const result = await ctx.registry.call(
|
|
419
|
+
finding.proposedAction.tool,
|
|
420
|
+
{
|
|
421
|
+
...finding.proposedAction.input,
|
|
422
|
+
...input.pending_operation_id ? { pending_operation_id: input.pending_operation_id } : {}
|
|
423
|
+
},
|
|
424
|
+
ctx
|
|
425
|
+
);
|
|
426
|
+
if (result.status === "applied") {
|
|
427
|
+
await store.setStatus(finding.id, "applied");
|
|
428
|
+
}
|
|
429
|
+
return { finding_id: finding.id, action: finding.proposedAction, result };
|
|
430
|
+
}
|
|
431
|
+
})
|
|
432
|
+
];
|
|
433
|
+
}
|
|
434
|
+
|
|
435
|
+
// src/credentials/store.ts
|
|
436
|
+
import { promises as fs2 } from "fs";
|
|
437
|
+
import path3 from "path";
|
|
438
|
+
var EMPTY = { version: 1, credentials: {} };
|
|
439
|
+
var CredentialStore = class {
|
|
440
|
+
constructor(dir = adportHome()) {
|
|
441
|
+
this.dir = dir;
|
|
442
|
+
}
|
|
443
|
+
dir;
|
|
444
|
+
file() {
|
|
445
|
+
return path3.join(this.dir, "credentials.json");
|
|
446
|
+
}
|
|
447
|
+
async read() {
|
|
448
|
+
try {
|
|
449
|
+
const raw = await fs2.readFile(this.file(), "utf8");
|
|
450
|
+
return JSON.parse(raw);
|
|
451
|
+
} catch (err) {
|
|
452
|
+
if (err.code === "ENOENT") return structuredClone(EMPTY);
|
|
453
|
+
throw err;
|
|
454
|
+
}
|
|
455
|
+
}
|
|
456
|
+
async write(data) {
|
|
457
|
+
await fs2.mkdir(this.dir, { recursive: true, mode: 448 });
|
|
458
|
+
await fs2.writeFile(this.file(), `${JSON.stringify(data, null, 2)}
|
|
459
|
+
`, { mode: 384 });
|
|
460
|
+
await fs2.chmod(this.file(), 384);
|
|
461
|
+
}
|
|
462
|
+
async get(provider) {
|
|
463
|
+
const file = await this.read();
|
|
464
|
+
return file.credentials[provider];
|
|
465
|
+
}
|
|
466
|
+
async list() {
|
|
467
|
+
const file = await this.read();
|
|
468
|
+
return Object.values(file.credentials);
|
|
469
|
+
}
|
|
470
|
+
async set(record) {
|
|
471
|
+
const file = await this.read();
|
|
472
|
+
const now = (/* @__PURE__ */ new Date()).toISOString();
|
|
473
|
+
const existing = file.credentials[record.provider];
|
|
474
|
+
const full = { ...record, createdAt: existing?.createdAt ?? now, updatedAt: now };
|
|
475
|
+
file.credentials[record.provider] = full;
|
|
476
|
+
await this.write(file);
|
|
477
|
+
return full;
|
|
478
|
+
}
|
|
479
|
+
async delete(provider) {
|
|
480
|
+
const file = await this.read();
|
|
481
|
+
if (!(provider in file.credentials)) return false;
|
|
482
|
+
delete file.credentials[provider];
|
|
483
|
+
await this.write(file);
|
|
484
|
+
return true;
|
|
485
|
+
}
|
|
486
|
+
};
|
|
487
|
+
|
|
488
|
+
// src/policy/policy.ts
|
|
489
|
+
import { promises as fs3 } from "fs";
|
|
490
|
+
import path4 from "path";
|
|
491
|
+
import YAML from "yaml";
|
|
492
|
+
import { z as z2 } from "zod";
|
|
493
|
+
var policySchema = z2.object({
|
|
494
|
+
/** Writes must be validated (dry-run) before they can be applied. */
|
|
495
|
+
require_validation: z2.boolean().default(true),
|
|
496
|
+
/** Created campaigns/ad groups/ads are coerced to PAUSED. */
|
|
497
|
+
paused_creation: z2.boolean().default(true),
|
|
498
|
+
/** Reject budget changes exceeding this percentage of the current value. null = no cap. */
|
|
499
|
+
max_budget_delta_pct: z2.number().positive().nullable().default(25),
|
|
500
|
+
/** Reject any budget set above this absolute value (micros). null = no cap. */
|
|
501
|
+
max_daily_budget_micros: z2.number().positive().nullable().default(null),
|
|
502
|
+
/** Account ids that no write may touch. */
|
|
503
|
+
protected_accounts: z2.array(z2.string()).default([]),
|
|
504
|
+
/** How long a validated pending operation stays applicable. */
|
|
505
|
+
pending_ttl_minutes: z2.number().positive().default(15)
|
|
506
|
+
});
|
|
507
|
+
var DEFAULT_POLICY = policySchema.parse({});
|
|
508
|
+
async function loadPolicy(explicitPath) {
|
|
509
|
+
const candidates = [
|
|
510
|
+
explicitPath,
|
|
511
|
+
process.env.ADPORT_POLICY,
|
|
512
|
+
path4.join(process.cwd(), "adport.policy.yaml"),
|
|
513
|
+
path4.join(adportHome(), "policy.yaml")
|
|
514
|
+
].filter((p) => Boolean(p));
|
|
515
|
+
for (const candidate of candidates) {
|
|
516
|
+
let raw;
|
|
517
|
+
try {
|
|
518
|
+
raw = await fs3.readFile(candidate, "utf8");
|
|
519
|
+
} catch {
|
|
520
|
+
if (candidate === explicitPath || candidate === process.env.ADPORT_POLICY) {
|
|
521
|
+
throw new Error(`Policy file not found: ${candidate}`);
|
|
522
|
+
}
|
|
523
|
+
continue;
|
|
524
|
+
}
|
|
525
|
+
const parsed = policySchema.safeParse(YAML.parse(raw) ?? {});
|
|
526
|
+
if (!parsed.success) {
|
|
527
|
+
throw new Error(`Invalid policy file ${candidate}: ${parsed.error.message}`);
|
|
528
|
+
}
|
|
529
|
+
return { policy: parsed.data, source: candidate };
|
|
530
|
+
}
|
|
531
|
+
return { policy: DEFAULT_POLICY, source: "defaults" };
|
|
532
|
+
}
|
|
533
|
+
|
|
534
|
+
// src/policy/pending.ts
|
|
535
|
+
import { promises as fs4 } from "fs";
|
|
536
|
+
import path5 from "path";
|
|
537
|
+
var PendingStore = class {
|
|
538
|
+
constructor(dir = path5.join(adportHome(), "pending")) {
|
|
539
|
+
this.dir = dir;
|
|
540
|
+
}
|
|
541
|
+
dir;
|
|
542
|
+
file(id) {
|
|
543
|
+
if (!/^[a-zA-Z0-9-]+$/.test(id)) throw new Error(`Invalid pending operation id: ${id}`);
|
|
544
|
+
return path5.join(this.dir, `${id}.json`);
|
|
545
|
+
}
|
|
546
|
+
async put(op) {
|
|
547
|
+
await fs4.mkdir(this.dir, { recursive: true, mode: 448 });
|
|
548
|
+
await fs4.writeFile(this.file(op.id), JSON.stringify(op, null, 2), { mode: 384 });
|
|
549
|
+
}
|
|
550
|
+
async get(id) {
|
|
551
|
+
try {
|
|
552
|
+
return JSON.parse(await fs4.readFile(this.file(id), "utf8"));
|
|
553
|
+
} catch (err) {
|
|
554
|
+
if (err.code === "ENOENT") return void 0;
|
|
555
|
+
throw err;
|
|
556
|
+
}
|
|
557
|
+
}
|
|
558
|
+
async delete(id) {
|
|
559
|
+
await fs4.rm(this.file(id), { force: true });
|
|
560
|
+
}
|
|
561
|
+
/** Remove expired entries. Called opportunistically; never throws on races. */
|
|
562
|
+
async sweep(now = /* @__PURE__ */ new Date()) {
|
|
563
|
+
let names;
|
|
564
|
+
try {
|
|
565
|
+
names = await fs4.readdir(this.dir);
|
|
566
|
+
} catch {
|
|
567
|
+
return;
|
|
568
|
+
}
|
|
569
|
+
for (const name of names) {
|
|
570
|
+
if (!name.endsWith(".json")) continue;
|
|
571
|
+
try {
|
|
572
|
+
const op = JSON.parse(await fs4.readFile(path5.join(this.dir, name), "utf8"));
|
|
573
|
+
if (Date.parse(op.expiresAt) < now.getTime()) {
|
|
574
|
+
await fs4.rm(path5.join(this.dir, name), { force: true });
|
|
575
|
+
}
|
|
576
|
+
} catch {
|
|
577
|
+
}
|
|
578
|
+
}
|
|
579
|
+
}
|
|
580
|
+
};
|
|
581
|
+
|
|
582
|
+
// src/policy/audit.ts
|
|
583
|
+
import { promises as fs5 } from "fs";
|
|
584
|
+
import path6 from "path";
|
|
585
|
+
var AuditLog = class {
|
|
586
|
+
constructor(dir = path6.join(adportHome(), "audit")) {
|
|
587
|
+
this.dir = dir;
|
|
588
|
+
}
|
|
589
|
+
dir;
|
|
590
|
+
file(now = /* @__PURE__ */ new Date()) {
|
|
591
|
+
const month = now.toISOString().slice(0, 7);
|
|
592
|
+
return path6.join(this.dir, `audit-${month}.jsonl`);
|
|
593
|
+
}
|
|
594
|
+
async append(entry) {
|
|
595
|
+
await fs5.mkdir(this.dir, { recursive: true, mode: 448 });
|
|
596
|
+
const full = { ts: (/* @__PURE__ */ new Date()).toISOString(), ...entry };
|
|
597
|
+
await fs5.appendFile(this.file(), `${JSON.stringify(full)}
|
|
598
|
+
`, { mode: 384 });
|
|
599
|
+
}
|
|
600
|
+
/** Most recent entries, newest last. Reads across all monthly files. */
|
|
601
|
+
async read(limit = 50) {
|
|
602
|
+
let names;
|
|
603
|
+
try {
|
|
604
|
+
names = (await fs5.readdir(this.dir)).filter((n) => n.endsWith(".jsonl")).sort();
|
|
605
|
+
} catch {
|
|
606
|
+
return [];
|
|
607
|
+
}
|
|
608
|
+
const entries = [];
|
|
609
|
+
for (const name of names) {
|
|
610
|
+
const raw = await fs5.readFile(path6.join(this.dir, name), "utf8");
|
|
611
|
+
for (const line of raw.split("\n")) {
|
|
612
|
+
if (line.trim()) entries.push(JSON.parse(line));
|
|
613
|
+
}
|
|
614
|
+
}
|
|
615
|
+
return entries.slice(-limit);
|
|
616
|
+
}
|
|
617
|
+
};
|
|
618
|
+
|
|
619
|
+
// src/policy/engine.ts
|
|
620
|
+
import { createHash, randomUUID } from "crypto";
|
|
621
|
+
function canonicalize(value) {
|
|
622
|
+
if (Array.isArray(value)) return value.map(canonicalize);
|
|
623
|
+
if (value && typeof value === "object") {
|
|
624
|
+
return Object.fromEntries(
|
|
625
|
+
Object.entries(value).sort(([a], [b]) => a.localeCompare(b)).map(([k, v]) => [k, canonicalize(v)])
|
|
626
|
+
);
|
|
627
|
+
}
|
|
628
|
+
return value;
|
|
629
|
+
}
|
|
630
|
+
function hashOperation(op) {
|
|
631
|
+
const canonical = canonicalize({
|
|
632
|
+
tool: op.tool,
|
|
633
|
+
provider: op.provider,
|
|
634
|
+
accountId: op.accountId,
|
|
635
|
+
kind: op.kind,
|
|
636
|
+
payload: op.payload
|
|
637
|
+
});
|
|
638
|
+
return createHash("sha256").update(JSON.stringify(canonical)).digest("hex");
|
|
639
|
+
}
|
|
640
|
+
var PolicyEngine = class {
|
|
641
|
+
constructor(policy, pending = new PendingStore(), audit = new AuditLog()) {
|
|
642
|
+
this.policy = policy;
|
|
643
|
+
this.pending = pending;
|
|
644
|
+
this.audit = audit;
|
|
645
|
+
}
|
|
646
|
+
policy;
|
|
647
|
+
pending;
|
|
648
|
+
audit;
|
|
649
|
+
guard() {
|
|
650
|
+
return { forcePausedCreation: this.policy.paused_creation };
|
|
651
|
+
}
|
|
652
|
+
async validate(provider, op) {
|
|
653
|
+
await this.pending.sweep();
|
|
654
|
+
await this.checkStaticPolicy(op);
|
|
655
|
+
const preview = await provider.previewWrite(op, this.guard());
|
|
656
|
+
await this.checkBudgetPolicy(op, preview);
|
|
657
|
+
const id = randomUUID();
|
|
658
|
+
const now = Date.now();
|
|
659
|
+
const expiresAt = new Date(now + this.policy.pending_ttl_minutes * 6e4).toISOString();
|
|
660
|
+
await this.pending.put({
|
|
661
|
+
id,
|
|
662
|
+
provider: provider.id,
|
|
663
|
+
opHash: hashOperation(op),
|
|
664
|
+
op,
|
|
665
|
+
preview,
|
|
666
|
+
createdAt: new Date(now).toISOString(),
|
|
667
|
+
expiresAt
|
|
668
|
+
});
|
|
669
|
+
await this.audit.append({
|
|
670
|
+
event: "validated",
|
|
671
|
+
provider: provider.id,
|
|
672
|
+
tool: op.tool,
|
|
673
|
+
accountId: op.accountId,
|
|
674
|
+
pendingId: id,
|
|
675
|
+
summary: preview.summary
|
|
676
|
+
});
|
|
677
|
+
return { pendingOperationId: id, preview, expiresAt };
|
|
678
|
+
}
|
|
679
|
+
async apply(provider, op, pendingId) {
|
|
680
|
+
const pending = await this.pending.get(pendingId);
|
|
681
|
+
if (!pending) {
|
|
682
|
+
throw new AdportError(
|
|
683
|
+
"PENDING_NOT_FOUND",
|
|
684
|
+
`No pending operation "${pendingId}". Validate first: call the tool without pending_operation_id.`
|
|
685
|
+
);
|
|
686
|
+
}
|
|
687
|
+
if (Date.parse(pending.expiresAt) < Date.now()) {
|
|
688
|
+
await this.pending.delete(pendingId);
|
|
689
|
+
throw new AdportError(
|
|
690
|
+
"PENDING_EXPIRED",
|
|
691
|
+
`Pending operation ${pendingId} expired at ${pending.expiresAt}. Validate again.`
|
|
692
|
+
);
|
|
693
|
+
}
|
|
694
|
+
if (pending.provider !== provider.id || pending.opHash !== hashOperation(op)) {
|
|
695
|
+
throw new AdportError(
|
|
696
|
+
"PENDING_MISMATCH",
|
|
697
|
+
"The operation differs from what was validated. Re-validate with the exact arguments you intend to apply."
|
|
698
|
+
);
|
|
699
|
+
}
|
|
700
|
+
await this.checkStaticPolicy(op);
|
|
701
|
+
const result = await provider.applyWrite(op, this.guard());
|
|
702
|
+
await this.audit.append({
|
|
703
|
+
event: "applied",
|
|
704
|
+
provider: provider.id,
|
|
705
|
+
tool: op.tool,
|
|
706
|
+
accountId: op.accountId,
|
|
707
|
+
pendingId,
|
|
708
|
+
summary: pending.preview.summary,
|
|
709
|
+
details: { resourceIds: result.resourceIds }
|
|
710
|
+
});
|
|
711
|
+
await this.pending.delete(pendingId);
|
|
712
|
+
return { result, preview: pending.preview };
|
|
713
|
+
}
|
|
714
|
+
async checkStaticPolicy(op) {
|
|
715
|
+
if (this.policy.protected_accounts.includes(op.accountId)) {
|
|
716
|
+
await this.reject(op, `Account ${op.accountId} is protected by policy`);
|
|
717
|
+
}
|
|
718
|
+
}
|
|
719
|
+
async checkBudgetPolicy(op, preview) {
|
|
720
|
+
const pctCap = this.policy.max_budget_delta_pct;
|
|
721
|
+
const absCap = this.policy.max_daily_budget_micros;
|
|
722
|
+
for (const delta of preview.budgetDeltas) {
|
|
723
|
+
if (absCap !== null && delta.toMicros > absCap) {
|
|
724
|
+
await this.reject(
|
|
725
|
+
op,
|
|
726
|
+
`${delta.target}: ${delta.toMicros} micros exceeds the absolute budget cap (${absCap})`
|
|
727
|
+
);
|
|
728
|
+
}
|
|
729
|
+
if (pctCap !== null && delta.fromMicros !== void 0 && delta.fromMicros > 0) {
|
|
730
|
+
const pct = Math.abs(delta.toMicros - delta.fromMicros) / delta.fromMicros * 100;
|
|
731
|
+
if (pct > pctCap) {
|
|
732
|
+
await this.reject(
|
|
733
|
+
op,
|
|
734
|
+
`${delta.target}: ${pct.toFixed(1)}% change exceeds the ${pctCap}% budget-delta cap`
|
|
735
|
+
);
|
|
736
|
+
}
|
|
737
|
+
}
|
|
738
|
+
}
|
|
739
|
+
}
|
|
740
|
+
async reject(op, reason) {
|
|
741
|
+
await this.audit.append({
|
|
742
|
+
event: "rejected",
|
|
743
|
+
provider: op.provider,
|
|
744
|
+
tool: op.tool,
|
|
745
|
+
accountId: op.accountId,
|
|
746
|
+
summary: reason
|
|
747
|
+
});
|
|
748
|
+
throw new AdportError("POLICY_VIOLATION", `Policy violation: ${reason}`, { policy: this.policy });
|
|
749
|
+
}
|
|
750
|
+
};
|
|
751
|
+
|
|
752
|
+
// src/tools/write.ts
|
|
753
|
+
import { z as z3 } from "zod";
|
|
754
|
+
var TWO_STEP_NOTE = "Two-step write: call WITHOUT pending_operation_id to get a dry-run preview and a pending_operation_id; call again with IDENTICAL arguments plus that id to apply.";
|
|
755
|
+
function guardedWriteTool(def) {
|
|
756
|
+
const input = def.payload.extend({
|
|
757
|
+
account_id: z3.string().min(1).describe("Target account id"),
|
|
758
|
+
pending_operation_id: z3.string().optional().describe("Omit to get a dry-run preview; pass the returned id to apply.")
|
|
759
|
+
});
|
|
760
|
+
return defineTool({
|
|
761
|
+
name: def.name,
|
|
762
|
+
namespace: def.namespace,
|
|
763
|
+
description: `${def.description}
|
|
764
|
+
|
|
765
|
+
${TWO_STEP_NOTE}`,
|
|
766
|
+
input,
|
|
767
|
+
annotations: { readOnly: false, destructive: def.destructive ?? false },
|
|
768
|
+
async handler(raw, ctx) {
|
|
769
|
+
const { account_id, pending_operation_id, ...payload } = raw;
|
|
770
|
+
const provider = ctx.providers.get(def.provider);
|
|
771
|
+
const op = {
|
|
772
|
+
tool: def.name,
|
|
773
|
+
provider: def.provider,
|
|
774
|
+
accountId: account_id,
|
|
775
|
+
kind: def.kind,
|
|
776
|
+
payload
|
|
777
|
+
};
|
|
778
|
+
if (!pending_operation_id) {
|
|
779
|
+
const outcome2 = await ctx.engine.validate(provider, op);
|
|
780
|
+
return {
|
|
781
|
+
status: "pending_validation",
|
|
782
|
+
applied: false,
|
|
783
|
+
pending_operation_id: outcome2.pendingOperationId,
|
|
784
|
+
expires_at: outcome2.expiresAt,
|
|
785
|
+
preview: outcome2.preview,
|
|
786
|
+
next_step: "Review the preview. To apply, call this tool again with the same arguments plus pending_operation_id."
|
|
787
|
+
};
|
|
788
|
+
}
|
|
789
|
+
const outcome = await ctx.engine.apply(provider, op, pending_operation_id);
|
|
790
|
+
return { status: "applied", applied: true, result: outcome.result, preview: outcome.preview };
|
|
791
|
+
}
|
|
792
|
+
});
|
|
793
|
+
}
|
|
794
|
+
|
|
795
|
+
// src/tools/builtin.ts
|
|
796
|
+
import { z as z4 } from "zod";
|
|
797
|
+
var dateRangeSchema2 = z4.union([
|
|
798
|
+
z4.enum(DATE_PRESETS),
|
|
799
|
+
z4.object({
|
|
800
|
+
start: z4.string().regex(/^\d{4}-\d{2}-\d{2}$/, "YYYY-MM-DD"),
|
|
801
|
+
end: z4.string().regex(/^\d{4}-\d{2}-\d{2}$/, "YYYY-MM-DD")
|
|
802
|
+
})
|
|
803
|
+
]);
|
|
804
|
+
function builtinTools() {
|
|
805
|
+
return [
|
|
806
|
+
defineTool({
|
|
807
|
+
name: "accounts_list",
|
|
808
|
+
namespace: "core",
|
|
809
|
+
description: "List connected ad accounts across all providers (or one provider).",
|
|
810
|
+
input: z4.object({
|
|
811
|
+
provider: z4.string().optional().describe('Limit to one provider id, e.g. "google".')
|
|
812
|
+
}),
|
|
813
|
+
annotations: { readOnly: true },
|
|
814
|
+
async handler(input, ctx) {
|
|
815
|
+
const providers = input.provider ? [ctx.providers.get(input.provider)] : ctx.providers.list();
|
|
816
|
+
const accounts = [];
|
|
817
|
+
for (const provider of providers) {
|
|
818
|
+
accounts.push(...await provider.listAccounts());
|
|
819
|
+
}
|
|
820
|
+
return { accounts };
|
|
821
|
+
}
|
|
822
|
+
}),
|
|
823
|
+
defineTool({
|
|
824
|
+
name: "report",
|
|
825
|
+
namespace: "core",
|
|
826
|
+
description: "Cross-platform performance report with normalized metrics (spend, clicks, conversions, ROAS, ...). Rows are capped by `limit`; the response says when it truncated.",
|
|
827
|
+
input: z4.object({
|
|
828
|
+
provider: z4.string().optional().describe("Limit to one provider id."),
|
|
829
|
+
account_ids: z4.array(z4.string()).optional(),
|
|
830
|
+
level: z4.enum(ENTITY_LEVELS).default("campaign"),
|
|
831
|
+
metrics: z4.array(z4.enum(METRICS)).min(1).default(["spend", "impressions", "clicks", "conversions"]),
|
|
832
|
+
date_range: dateRangeSchema2.default("last_7_days"),
|
|
833
|
+
limit: z4.number().int().positive().max(1e3).default(100)
|
|
834
|
+
}),
|
|
835
|
+
annotations: { readOnly: true },
|
|
836
|
+
async handler(input, ctx) {
|
|
837
|
+
const providers = input.provider ? [ctx.providers.get(input.provider)] : ctx.providers.list();
|
|
838
|
+
const query = {
|
|
839
|
+
accountIds: input.account_ids,
|
|
840
|
+
level: input.level,
|
|
841
|
+
metrics: input.metrics,
|
|
842
|
+
dateRange: input.date_range,
|
|
843
|
+
limit: input.limit
|
|
844
|
+
};
|
|
845
|
+
const rows = [];
|
|
846
|
+
for (const provider of providers) {
|
|
847
|
+
const report = await provider.report(query);
|
|
848
|
+
rows.push(...report.rows);
|
|
849
|
+
}
|
|
850
|
+
const truncated = rows.length > input.limit;
|
|
851
|
+
return { rows: rows.slice(0, input.limit), truncated };
|
|
852
|
+
}
|
|
853
|
+
})
|
|
854
|
+
];
|
|
855
|
+
}
|
|
856
|
+
|
|
857
|
+
// src/testing/mock-provider.ts
|
|
858
|
+
import { z as z5 } from "zod";
|
|
859
|
+
function seedAccounts() {
|
|
860
|
+
return [
|
|
861
|
+
{
|
|
862
|
+
provider: "mock",
|
|
863
|
+
id: "mock-1",
|
|
864
|
+
name: "Acme DTC Store",
|
|
865
|
+
currency: "EUR",
|
|
866
|
+
status: "ENABLED",
|
|
867
|
+
campaigns: [
|
|
868
|
+
{ id: "c1", name: "Brand Search", status: "ENABLED", dailyBudgetMicros: 1e7 },
|
|
869
|
+
{ id: "c2", name: "Prospecting", status: "ENABLED", dailyBudgetMicros: 25e6 },
|
|
870
|
+
// Deliberately conversion-less: exercises the audit harness.
|
|
871
|
+
{ id: "c4", name: "Legacy Retargeting", status: "ENABLED", dailyBudgetMicros: 8e6 }
|
|
872
|
+
]
|
|
873
|
+
},
|
|
874
|
+
{
|
|
875
|
+
provider: "mock",
|
|
876
|
+
id: "mock-2",
|
|
877
|
+
name: "Beta App",
|
|
878
|
+
currency: "USD",
|
|
879
|
+
status: "ENABLED",
|
|
880
|
+
campaigns: [{ id: "c3", name: "Install Campaign", status: "PAUSED", dailyBudgetMicros: 5e6 }]
|
|
881
|
+
}
|
|
882
|
+
];
|
|
883
|
+
}
|
|
884
|
+
var MockProvider = class {
|
|
885
|
+
id = "mock";
|
|
886
|
+
accounts = seedAccounts();
|
|
887
|
+
capabilities() {
|
|
888
|
+
return { serverDryRun: false };
|
|
889
|
+
}
|
|
890
|
+
standardActions() {
|
|
891
|
+
return {
|
|
892
|
+
pauseCampaign: (accountId, campaignId) => ({
|
|
893
|
+
tool: "mock_set_campaign_status",
|
|
894
|
+
input: { account_id: accountId, campaign_id: campaignId, status: "PAUSED" }
|
|
895
|
+
})
|
|
896
|
+
};
|
|
897
|
+
}
|
|
898
|
+
async listAccounts() {
|
|
899
|
+
return this.accounts.map(({ campaigns: _campaigns, ...account }) => account);
|
|
900
|
+
}
|
|
901
|
+
listCampaigns(accountId) {
|
|
902
|
+
return [...this.account(accountId).campaigns];
|
|
903
|
+
}
|
|
904
|
+
async report(query) {
|
|
905
|
+
const range = resolveDateRange(query.dateRange);
|
|
906
|
+
const days = rangeDayCount(range);
|
|
907
|
+
const rows = [];
|
|
908
|
+
for (const account of this.accounts) {
|
|
909
|
+
if (query.accountIds && !query.accountIds.includes(account.id)) continue;
|
|
910
|
+
for (const [index, campaign] of account.campaigns.entries()) {
|
|
911
|
+
if (campaign.status === "REMOVED") continue;
|
|
912
|
+
rows.push({
|
|
913
|
+
provider: this.id,
|
|
914
|
+
accountId: account.id,
|
|
915
|
+
entity: { level: "campaign", id: campaign.id, name: campaign.name, status: campaign.status },
|
|
916
|
+
metrics: mockMetrics(query.metrics, index, days, campaign.dailyBudgetMicros, campaign.id === "c4")
|
|
917
|
+
});
|
|
918
|
+
}
|
|
919
|
+
}
|
|
920
|
+
return { rows };
|
|
921
|
+
}
|
|
922
|
+
async previewWrite(op, guard) {
|
|
923
|
+
return this.plan(op, guard).preview;
|
|
924
|
+
}
|
|
925
|
+
async applyWrite(op, guard) {
|
|
926
|
+
return this.plan(op, guard).apply();
|
|
927
|
+
}
|
|
928
|
+
account(accountId) {
|
|
929
|
+
const account = this.accounts.find((a) => a.id === accountId);
|
|
930
|
+
if (!account) throw new AdportError("PROVIDER_ERROR", `mock: unknown account ${accountId}`);
|
|
931
|
+
return account;
|
|
932
|
+
}
|
|
933
|
+
campaign(accountId, campaignId) {
|
|
934
|
+
const campaign = this.account(accountId).campaigns.find((c) => c.id === campaignId);
|
|
935
|
+
if (!campaign) throw new AdportError("PROVIDER_ERROR", `mock: unknown campaign ${campaignId}`);
|
|
936
|
+
return campaign;
|
|
937
|
+
}
|
|
938
|
+
/** Compute preview and applier together so dry-run and apply can never drift. */
|
|
939
|
+
plan(op, guard) {
|
|
940
|
+
const base = { coercions: [], budgetDeltas: [], serverValidated: false };
|
|
941
|
+
switch (op.tool) {
|
|
942
|
+
case "mock_create_campaign": {
|
|
943
|
+
const payload = op.payload;
|
|
944
|
+
let status = payload.status ?? "ENABLED";
|
|
945
|
+
const coercions = [];
|
|
946
|
+
if (guard.forcePausedCreation && status === "ENABLED") {
|
|
947
|
+
status = "PAUSED";
|
|
948
|
+
coercions.push("status coerced to PAUSED by policy (paused_creation)");
|
|
949
|
+
}
|
|
950
|
+
const account = this.account(op.accountId);
|
|
951
|
+
const id = `c${100 + account.campaigns.length}`;
|
|
952
|
+
return {
|
|
953
|
+
preview: {
|
|
954
|
+
...base,
|
|
955
|
+
summary: `Create campaign "${payload.name}" (${status}) with daily budget ${payload.daily_budget_micros} micros`,
|
|
956
|
+
changes: [`+ campaign ${id} "${payload.name}" status=${status}`],
|
|
957
|
+
coercions,
|
|
958
|
+
budgetDeltas: [{ target: `new campaign "${payload.name}" daily budget`, toMicros: payload.daily_budget_micros }]
|
|
959
|
+
},
|
|
960
|
+
apply: () => {
|
|
961
|
+
account.campaigns.push({ id, name: payload.name, status, dailyBudgetMicros: payload.daily_budget_micros });
|
|
962
|
+
return { applied: true, resourceIds: [id] };
|
|
963
|
+
}
|
|
964
|
+
};
|
|
965
|
+
}
|
|
966
|
+
case "mock_set_budget": {
|
|
967
|
+
const payload = op.payload;
|
|
968
|
+
const campaign = this.campaign(op.accountId, payload.campaign_id);
|
|
969
|
+
return {
|
|
970
|
+
preview: {
|
|
971
|
+
...base,
|
|
972
|
+
summary: `Change "${campaign.name}" daily budget ${campaign.dailyBudgetMicros} \u2192 ${payload.daily_budget_micros} micros`,
|
|
973
|
+
changes: [`~ campaign ${campaign.id} daily_budget ${campaign.dailyBudgetMicros} \u2192 ${payload.daily_budget_micros}`],
|
|
974
|
+
budgetDeltas: [
|
|
975
|
+
{
|
|
976
|
+
target: `campaign "${campaign.name}" daily budget`,
|
|
977
|
+
fromMicros: campaign.dailyBudgetMicros,
|
|
978
|
+
toMicros: payload.daily_budget_micros
|
|
979
|
+
}
|
|
980
|
+
]
|
|
981
|
+
},
|
|
982
|
+
apply: () => {
|
|
983
|
+
campaign.dailyBudgetMicros = payload.daily_budget_micros;
|
|
984
|
+
return { applied: true, resourceIds: [campaign.id] };
|
|
985
|
+
}
|
|
986
|
+
};
|
|
987
|
+
}
|
|
988
|
+
case "mock_set_campaign_status": {
|
|
989
|
+
const payload = op.payload;
|
|
990
|
+
const campaign = this.campaign(op.accountId, payload.campaign_id);
|
|
991
|
+
return {
|
|
992
|
+
preview: {
|
|
993
|
+
...base,
|
|
994
|
+
summary: `Set "${campaign.name}" status ${campaign.status} \u2192 ${payload.status}`,
|
|
995
|
+
changes: [`~ campaign ${campaign.id} status ${campaign.status} \u2192 ${payload.status}`]
|
|
996
|
+
},
|
|
997
|
+
apply: () => {
|
|
998
|
+
campaign.status = payload.status;
|
|
999
|
+
return { applied: true, resourceIds: [campaign.id] };
|
|
1000
|
+
}
|
|
1001
|
+
};
|
|
1002
|
+
}
|
|
1003
|
+
case "mock_remove_campaign": {
|
|
1004
|
+
const payload = op.payload;
|
|
1005
|
+
const campaign = this.campaign(op.accountId, payload.campaign_id);
|
|
1006
|
+
return {
|
|
1007
|
+
preview: {
|
|
1008
|
+
...base,
|
|
1009
|
+
summary: `PERMANENTLY remove campaign "${campaign.name}"`,
|
|
1010
|
+
changes: [`- campaign ${campaign.id} "${campaign.name}"`]
|
|
1011
|
+
},
|
|
1012
|
+
apply: () => {
|
|
1013
|
+
campaign.status = "REMOVED";
|
|
1014
|
+
return { applied: true, resourceIds: [campaign.id] };
|
|
1015
|
+
}
|
|
1016
|
+
};
|
|
1017
|
+
}
|
|
1018
|
+
default:
|
|
1019
|
+
throw new AdportError("PROVIDER_ERROR", `mock: unsupported write tool ${op.tool}`);
|
|
1020
|
+
}
|
|
1021
|
+
}
|
|
1022
|
+
};
|
|
1023
|
+
function mockMetrics(requested, seed, days, dailyBudgetMicros, zeroConversions = false) {
|
|
1024
|
+
const spend = (dailyBudgetMicros / 1e6 * 0.83 + seed) * days;
|
|
1025
|
+
const impressions = (2e3 + seed * 700) * days;
|
|
1026
|
+
const clicks = (90 + seed * 35) * days;
|
|
1027
|
+
const conversions = zeroConversions ? 0 : (4 + seed) * days;
|
|
1028
|
+
const conversionValue = conversions * (35 + seed * 5);
|
|
1029
|
+
const all = {
|
|
1030
|
+
spend: round2(spend),
|
|
1031
|
+
impressions,
|
|
1032
|
+
clicks,
|
|
1033
|
+
conversions,
|
|
1034
|
+
conversion_value: round2(conversionValue),
|
|
1035
|
+
ctr: round2(clicks / impressions * 100),
|
|
1036
|
+
cpc: round2(spend / clicks),
|
|
1037
|
+
cpm: round2(spend / impressions * 1e3),
|
|
1038
|
+
cpa: conversions > 0 ? round2(spend / conversions) : 0,
|
|
1039
|
+
roas: spend > 0 ? round2(conversionValue / spend) : 0
|
|
1040
|
+
};
|
|
1041
|
+
return Object.fromEntries(requested.map((m) => [m, all[m]]));
|
|
1042
|
+
}
|
|
1043
|
+
function round2(n) {
|
|
1044
|
+
return Math.round(n * 100) / 100;
|
|
1045
|
+
}
|
|
1046
|
+
function mockTools() {
|
|
1047
|
+
return [
|
|
1048
|
+
defineTool({
|
|
1049
|
+
name: "mock_list_campaigns",
|
|
1050
|
+
namespace: "mock",
|
|
1051
|
+
description: "List campaigns in a mock account (id, name, status, daily budget).",
|
|
1052
|
+
input: z5.object({ account_id: z5.string() }),
|
|
1053
|
+
annotations: { readOnly: true },
|
|
1054
|
+
async handler(input, ctx) {
|
|
1055
|
+
const provider = ctx.providers.get("mock");
|
|
1056
|
+
return { campaigns: provider.listCampaigns(input.account_id) };
|
|
1057
|
+
}
|
|
1058
|
+
}),
|
|
1059
|
+
guardedWriteTool({
|
|
1060
|
+
name: "mock_create_campaign",
|
|
1061
|
+
namespace: "mock",
|
|
1062
|
+
description: "Create a campaign in the mock account.",
|
|
1063
|
+
provider: "mock",
|
|
1064
|
+
kind: "create",
|
|
1065
|
+
payload: z5.object({
|
|
1066
|
+
name: z5.string().min(1),
|
|
1067
|
+
daily_budget_micros: z5.number().int().positive(),
|
|
1068
|
+
status: z5.enum(["ENABLED", "PAUSED"]).optional()
|
|
1069
|
+
})
|
|
1070
|
+
}),
|
|
1071
|
+
guardedWriteTool({
|
|
1072
|
+
name: "mock_set_budget",
|
|
1073
|
+
namespace: "mock",
|
|
1074
|
+
description: "Change a mock campaign daily budget.",
|
|
1075
|
+
provider: "mock",
|
|
1076
|
+
kind: "update",
|
|
1077
|
+
payload: z5.object({ campaign_id: z5.string(), daily_budget_micros: z5.number().int().positive() })
|
|
1078
|
+
}),
|
|
1079
|
+
guardedWriteTool({
|
|
1080
|
+
name: "mock_set_campaign_status",
|
|
1081
|
+
namespace: "mock",
|
|
1082
|
+
description: "Enable or pause a mock campaign.",
|
|
1083
|
+
provider: "mock",
|
|
1084
|
+
kind: "update",
|
|
1085
|
+
payload: z5.object({ campaign_id: z5.string(), status: z5.enum(["ENABLED", "PAUSED"]) })
|
|
1086
|
+
}),
|
|
1087
|
+
guardedWriteTool({
|
|
1088
|
+
name: "mock_remove_campaign",
|
|
1089
|
+
namespace: "mock",
|
|
1090
|
+
description: "Permanently remove a mock campaign.",
|
|
1091
|
+
provider: "mock",
|
|
1092
|
+
kind: "remove",
|
|
1093
|
+
destructive: true,
|
|
1094
|
+
payload: z5.object({ campaign_id: z5.string() })
|
|
1095
|
+
})
|
|
1096
|
+
];
|
|
1097
|
+
}
|
|
1098
|
+
|
|
1099
|
+
// src/context.ts
|
|
1100
|
+
async function createContext(options = {}) {
|
|
1101
|
+
const modules = options.providerModules ?? [];
|
|
1102
|
+
const includeMock = options.includeMock ?? modules.length === 0;
|
|
1103
|
+
const providers = new ProviderRegistry();
|
|
1104
|
+
const { policy, source } = await loadPolicy(options.policyPath);
|
|
1105
|
+
const engine = new PolicyEngine(policy);
|
|
1106
|
+
const credentials = new CredentialStore();
|
|
1107
|
+
const registry = new ToolRegistry();
|
|
1108
|
+
registry.register(builtinTools());
|
|
1109
|
+
registry.register(auditTools());
|
|
1110
|
+
for (const module of modules) {
|
|
1111
|
+
providers.register(module.provider);
|
|
1112
|
+
registry.register(module.tools);
|
|
1113
|
+
}
|
|
1114
|
+
if (includeMock) {
|
|
1115
|
+
providers.register(new MockProvider());
|
|
1116
|
+
registry.register(mockTools());
|
|
1117
|
+
}
|
|
1118
|
+
const ctx = { providers, engine, credentials };
|
|
1119
|
+
ctx.registry = registry;
|
|
1120
|
+
return { ctx, registry, policySource: source };
|
|
1121
|
+
}
|
|
1122
|
+
export {
|
|
1123
|
+
AdportError,
|
|
1124
|
+
AuditLog,
|
|
1125
|
+
AuditRunner,
|
|
1126
|
+
CredentialStore,
|
|
1127
|
+
DATE_PRESETS,
|
|
1128
|
+
DEFAULT_POLICY,
|
|
1129
|
+
ENTITY_LEVELS,
|
|
1130
|
+
FindingsStore,
|
|
1131
|
+
METRICS,
|
|
1132
|
+
MockProvider,
|
|
1133
|
+
PendingStore,
|
|
1134
|
+
PolicyEngine,
|
|
1135
|
+
ProviderRegistry,
|
|
1136
|
+
ToolRegistry,
|
|
1137
|
+
adportHome,
|
|
1138
|
+
auditTools,
|
|
1139
|
+
builtinTools,
|
|
1140
|
+
corePerformancePack,
|
|
1141
|
+
createContext,
|
|
1142
|
+
defineTool,
|
|
1143
|
+
guardedWriteTool,
|
|
1144
|
+
hashOperation,
|
|
1145
|
+
loadPolicy,
|
|
1146
|
+
mockTools,
|
|
1147
|
+
policySchema,
|
|
1148
|
+
rangeDayCount,
|
|
1149
|
+
resolveDateRange
|
|
1150
|
+
};
|
|
1151
|
+
//# sourceMappingURL=index.js.map
|