@iann29/rastro 0.9.0 → 0.10.1

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 (265) hide show
  1. package/README.md +9 -1
  2. package/agent/integration.md +23 -1
  3. package/agent/manifest.json +4 -4
  4. package/dist/client/federation.d.ts +114 -9
  5. package/dist/client/federation.d.ts.map +1 -1
  6. package/dist/client/federation.js +54 -2
  7. package/dist/client/federation.js.map +1 -1
  8. package/dist/client/index.d.ts +642 -8
  9. package/dist/client/index.d.ts.map +1 -1
  10. package/dist/client/index.js +391 -9
  11. package/dist/client/index.js.map +1 -1
  12. package/dist/component/_generated/api.d.ts +14 -0
  13. package/dist/component/_generated/api.d.ts.map +1 -1
  14. package/dist/component/_generated/api.js.map +1 -1
  15. package/dist/component/_generated/component.d.ts +278 -1
  16. package/dist/component/_generated/component.d.ts.map +1 -1
  17. package/dist/component/_generated/server.d.ts +4 -0
  18. package/dist/component/_generated/server.d.ts.map +1 -1
  19. package/dist/component/_generated/server.js.map +1 -1
  20. package/dist/component/constants.d.ts +5 -0
  21. package/dist/component/constants.d.ts.map +1 -1
  22. package/dist/component/constants.js +10 -0
  23. package/dist/component/constants.js.map +1 -1
  24. package/dist/component/convex.config.d.ts +4 -0
  25. package/dist/component/convex.config.js +9 -0
  26. package/dist/component/convex.config.js.map +1 -1
  27. package/dist/component/errors.d.ts +1 -1
  28. package/dist/component/errors.d.ts.map +1 -1
  29. package/dist/component/errors.js.map +1 -1
  30. package/dist/component/leads.d.ts +69 -3
  31. package/dist/component/leads.d.ts.map +1 -1
  32. package/dist/component/leads.js +419 -16
  33. package/dist/component/leads.js.map +1 -1
  34. package/dist/component/meta.d.ts +358 -0
  35. package/dist/component/meta.d.ts.map +1 -0
  36. package/dist/component/meta.js +1295 -0
  37. package/dist/component/meta.js.map +1 -0
  38. package/dist/component/metaCapi.d.ts +162 -0
  39. package/dist/component/metaCapi.d.ts.map +1 -0
  40. package/dist/component/metaCapi.js +754 -0
  41. package/dist/component/metaCapi.js.map +1 -0
  42. package/dist/component/metaFake.d.ts +5 -0
  43. package/dist/component/metaFake.d.ts.map +1 -0
  44. package/dist/component/metaFake.js +225 -0
  45. package/dist/component/metaFake.js.map +1 -0
  46. package/dist/component/metaGraph.d.ts +165 -0
  47. package/dist/component/metaGraph.d.ts.map +1 -0
  48. package/dist/component/metaGraph.js +507 -0
  49. package/dist/component/metaGraph.js.map +1 -0
  50. package/dist/component/metaSync.d.ts +186 -0
  51. package/dist/component/metaSync.d.ts.map +1 -0
  52. package/dist/component/metaSync.js +961 -0
  53. package/dist/component/metaSync.js.map +1 -0
  54. package/dist/component/pipeline.d.ts +99 -0
  55. package/dist/component/pipeline.d.ts.map +1 -0
  56. package/dist/component/pipeline.js +337 -0
  57. package/dist/component/pipeline.js.map +1 -0
  58. package/dist/component/schema.d.ts +404 -2
  59. package/dist/component/schema.d.ts.map +1 -1
  60. package/dist/component/schema.js +168 -2
  61. package/dist/component/schema.js.map +1 -1
  62. package/dist/component/secrets.d.ts +15 -0
  63. package/dist/component/secrets.d.ts.map +1 -0
  64. package/dist/component/secrets.js +67 -0
  65. package/dist/component/secrets.js.map +1 -0
  66. package/dist/component/validators.d.ts +1052 -15
  67. package/dist/component/validators.d.ts.map +1 -1
  68. package/dist/component/validators.js +307 -3
  69. package/dist/component/validators.js.map +1 -1
  70. package/dist/tracker/generated.d.ts +1 -1
  71. package/dist/tracker/generated.d.ts.map +1 -1
  72. package/dist/tracker/generated.js +1 -1
  73. package/dist/tracker/generated.js.map +1 -1
  74. package/dist/ui/campaigns/CampaignsView.d.ts +48 -0
  75. package/dist/ui/campaigns/CampaignsView.d.ts.map +1 -0
  76. package/dist/ui/campaigns/CampaignsView.js +47 -0
  77. package/dist/ui/campaigns/CampaignsView.js.map +1 -0
  78. package/dist/ui/campaigns/Conversions.d.ts +16 -0
  79. package/dist/ui/campaigns/Conversions.d.ts.map +1 -0
  80. package/dist/ui/campaigns/Conversions.js +40 -0
  81. package/dist/ui/campaigns/Conversions.js.map +1 -0
  82. package/dist/ui/campaigns/Drawer.d.ts +24 -0
  83. package/dist/ui/campaigns/Drawer.d.ts.map +1 -0
  84. package/dist/ui/campaigns/Drawer.js +120 -0
  85. package/dist/ui/campaigns/Drawer.js.map +1 -0
  86. package/dist/ui/campaigns/Icons.d.ts +35 -0
  87. package/dist/ui/campaigns/Icons.d.ts.map +1 -0
  88. package/dist/ui/campaigns/Icons.js +41 -0
  89. package/dist/ui/campaigns/Icons.js.map +1 -0
  90. package/dist/ui/campaigns/Summary.d.ts +21 -0
  91. package/dist/ui/campaigns/Summary.d.ts.map +1 -0
  92. package/dist/ui/campaigns/Summary.js +56 -0
  93. package/dist/ui/campaigns/Summary.js.map +1 -0
  94. package/dist/ui/campaigns/Thumbnail.d.ts +11 -0
  95. package/dist/ui/campaigns/Thumbnail.d.ts.map +1 -0
  96. package/dist/ui/campaigns/Thumbnail.js +23 -0
  97. package/dist/ui/campaigns/Thumbnail.js.map +1 -0
  98. package/dist/ui/campaigns/Tree.d.ts +12 -0
  99. package/dist/ui/campaigns/Tree.d.ts.map +1 -0
  100. package/dist/ui/campaigns/Tree.js +105 -0
  101. package/dist/ui/campaigns/Tree.js.map +1 -0
  102. package/dist/ui/campaigns/glyphs.d.ts +20 -0
  103. package/dist/ui/campaigns/glyphs.d.ts.map +1 -0
  104. package/dist/ui/campaigns/glyphs.js +19 -0
  105. package/dist/ui/campaigns/glyphs.js.map +1 -0
  106. package/dist/ui/campaigns/index.d.ts +4 -0
  107. package/dist/ui/campaigns/index.d.ts.map +1 -0
  108. package/dist/ui/campaigns/index.js +3 -0
  109. package/dist/ui/campaigns/index.js.map +1 -0
  110. package/dist/ui/campaigns/labels.d.ts +53 -0
  111. package/dist/ui/campaigns/labels.d.ts.map +1 -0
  112. package/dist/ui/campaigns/labels.js +169 -0
  113. package/dist/ui/campaigns/labels.js.map +1 -0
  114. package/dist/ui/campaigns/model.d.ts +99 -0
  115. package/dist/ui/campaigns/model.d.ts.map +1 -0
  116. package/dist/ui/campaigns/model.js +230 -0
  117. package/dist/ui/campaigns/model.js.map +1 -0
  118. package/dist/ui/campaigns/modes.d.ts +18 -0
  119. package/dist/ui/campaigns/modes.d.ts.map +1 -0
  120. package/dist/ui/campaigns/modes.js +7 -0
  121. package/dist/ui/campaigns/modes.js.map +1 -0
  122. package/dist/ui/index.d.ts +21 -0
  123. package/dist/ui/index.d.ts.map +1 -0
  124. package/dist/ui/index.js +20 -0
  125. package/dist/ui/index.js.map +1 -0
  126. package/dist/ui/integrations/AccountPicker.d.ts +26 -0
  127. package/dist/ui/integrations/AccountPicker.d.ts.map +1 -0
  128. package/dist/ui/integrations/AccountPicker.js +101 -0
  129. package/dist/ui/integrations/AccountPicker.js.map +1 -0
  130. package/dist/ui/integrations/ClientCards.d.ts +17 -0
  131. package/dist/ui/integrations/ClientCards.d.ts.map +1 -0
  132. package/dist/ui/integrations/ClientCards.js +42 -0
  133. package/dist/ui/integrations/ClientCards.js.map +1 -0
  134. package/dist/ui/integrations/ClientsTable.d.ts +36 -0
  135. package/dist/ui/integrations/ClientsTable.d.ts.map +1 -0
  136. package/dist/ui/integrations/ClientsTable.js +143 -0
  137. package/dist/ui/integrations/ClientsTable.js.map +1 -0
  138. package/dist/ui/integrations/Connect.d.ts +35 -0
  139. package/dist/ui/integrations/Connect.d.ts.map +1 -0
  140. package/dist/ui/integrations/Connect.js +62 -0
  141. package/dist/ui/integrations/Connect.js.map +1 -0
  142. package/dist/ui/integrations/Connection.d.ts +37 -0
  143. package/dist/ui/integrations/Connection.d.ts.map +1 -0
  144. package/dist/ui/integrations/Connection.js +108 -0
  145. package/dist/ui/integrations/Connection.js.map +1 -0
  146. package/dist/ui/integrations/Dialogs.d.ts +21 -0
  147. package/dist/ui/integrations/Dialogs.d.ts.map +1 -0
  148. package/dist/ui/integrations/Dialogs.js +34 -0
  149. package/dist/ui/integrations/Dialogs.js.map +1 -0
  150. package/dist/ui/integrations/EventsFeed.d.ts +15 -0
  151. package/dist/ui/integrations/EventsFeed.d.ts.map +1 -0
  152. package/dist/ui/integrations/EventsFeed.js +61 -0
  153. package/dist/ui/integrations/EventsFeed.js.map +1 -0
  154. package/dist/ui/integrations/EventsSheet.d.ts +34 -0
  155. package/dist/ui/integrations/EventsSheet.d.ts.map +1 -0
  156. package/dist/ui/integrations/EventsSheet.js +136 -0
  157. package/dist/ui/integrations/EventsSheet.js.map +1 -0
  158. package/dist/ui/integrations/Icons.d.ts +38 -0
  159. package/dist/ui/integrations/Icons.d.ts.map +1 -0
  160. package/dist/ui/integrations/Icons.js +40 -0
  161. package/dist/ui/integrations/Icons.js.map +1 -0
  162. package/dist/ui/integrations/IntegrationsView.d.ts +15 -0
  163. package/dist/ui/integrations/IntegrationsView.d.ts.map +1 -0
  164. package/dist/ui/integrations/IntegrationsView.js +141 -0
  165. package/dist/ui/integrations/IntegrationsView.js.map +1 -0
  166. package/dist/ui/integrations/Overlay.d.ts +18 -0
  167. package/dist/ui/integrations/Overlay.d.ts.map +1 -0
  168. package/dist/ui/integrations/Overlay.js +36 -0
  169. package/dist/ui/integrations/Overlay.js.map +1 -0
  170. package/dist/ui/integrations/Partner.d.ts +14 -0
  171. package/dist/ui/integrations/Partner.d.ts.map +1 -0
  172. package/dist/ui/integrations/Partner.js +38 -0
  173. package/dist/ui/integrations/Partner.js.map +1 -0
  174. package/dist/ui/integrations/RowMenu.d.ts +15 -0
  175. package/dist/ui/integrations/RowMenu.d.ts.map +1 -0
  176. package/dist/ui/integrations/RowMenu.js +47 -0
  177. package/dist/ui/integrations/RowMenu.js.map +1 -0
  178. package/dist/ui/integrations/UnlinkDialog.d.ts +18 -0
  179. package/dist/ui/integrations/UnlinkDialog.d.ts.map +1 -0
  180. package/dist/ui/integrations/UnlinkDialog.js +29 -0
  181. package/dist/ui/integrations/UnlinkDialog.js.map +1 -0
  182. package/dist/ui/integrations/audience.d.ts +12 -0
  183. package/dist/ui/integrations/audience.d.ts.map +1 -0
  184. package/dist/ui/integrations/audience.js +12 -0
  185. package/dist/ui/integrations/audience.js.map +1 -0
  186. package/dist/ui/integrations/format.d.ts +28 -0
  187. package/dist/ui/integrations/format.d.ts.map +1 -0
  188. package/dist/ui/integrations/format.js +101 -0
  189. package/dist/ui/integrations/format.js.map +1 -0
  190. package/dist/ui/integrations/index.d.ts +5 -0
  191. package/dist/ui/integrations/index.d.ts.map +1 -0
  192. package/dist/ui/integrations/index.js +3 -0
  193. package/dist/ui/integrations/index.js.map +1 -0
  194. package/dist/ui/integrations/modal.d.ts +10 -0
  195. package/dist/ui/integrations/modal.d.ts.map +1 -0
  196. package/dist/ui/integrations/modal.js +77 -0
  197. package/dist/ui/integrations/modal.js.map +1 -0
  198. package/dist/ui/integrations/model.d.ts +99 -0
  199. package/dist/ui/integrations/model.d.ts.map +1 -0
  200. package/dist/ui/integrations/model.js +164 -0
  201. package/dist/ui/integrations/model.js.map +1 -0
  202. package/dist/ui/integrations/parts.d.ts +36 -0
  203. package/dist/ui/integrations/parts.d.ts.map +1 -0
  204. package/dist/ui/integrations/parts.js +85 -0
  205. package/dist/ui/integrations/parts.js.map +1 -0
  206. package/dist/ui/integrations/types.d.ts +146 -0
  207. package/dist/ui/integrations/types.d.ts.map +1 -0
  208. package/dist/ui/integrations/types.js +2 -0
  209. package/dist/ui/integrations/types.js.map +1 -0
  210. package/dist/ui/metrics.d.ts +106 -0
  211. package/dist/ui/metrics.d.ts.map +1 -0
  212. package/dist/ui/metrics.js +166 -0
  213. package/dist/ui/metrics.js.map +1 -0
  214. package/dist/ui/portfolio/PortfolioView.d.ts +26 -0
  215. package/dist/ui/portfolio/PortfolioView.d.ts.map +1 -0
  216. package/dist/ui/portfolio/PortfolioView.js +216 -0
  217. package/dist/ui/portfolio/PortfolioView.js.map +1 -0
  218. package/dist/ui/portfolio/SpendChart.d.ts +10 -0
  219. package/dist/ui/portfolio/SpendChart.d.ts.map +1 -0
  220. package/dist/ui/portfolio/SpendChart.js +196 -0
  221. package/dist/ui/portfolio/SpendChart.js.map +1 -0
  222. package/dist/ui/portfolio/alerts.d.ts +74 -0
  223. package/dist/ui/portfolio/alerts.d.ts.map +1 -0
  224. package/dist/ui/portfolio/alerts.js +291 -0
  225. package/dist/ui/portfolio/alerts.js.map +1 -0
  226. package/dist/ui/portfolio/figures.d.ts +79 -0
  227. package/dist/ui/portfolio/figures.d.ts.map +1 -0
  228. package/dist/ui/portfolio/figures.js +103 -0
  229. package/dist/ui/portfolio/figures.js.map +1 -0
  230. package/dist/ui/portfolio/format.d.ts +8 -0
  231. package/dist/ui/portfolio/format.d.ts.map +1 -0
  232. package/dist/ui/portfolio/format.js +28 -0
  233. package/dist/ui/portfolio/format.js.map +1 -0
  234. package/dist/ui/sample.d.ts +536 -0
  235. package/dist/ui/sample.d.ts.map +1 -0
  236. package/dist/ui/sample.js +574 -0
  237. package/dist/ui/sample.js.map +1 -0
  238. package/dist/ui/seed.d.ts +188 -0
  239. package/dist/ui/seed.d.ts.map +1 -0
  240. package/dist/ui/seed.js +563 -0
  241. package/dist/ui/seed.js.map +1 -0
  242. package/dist/ui/ui.css +3424 -0
  243. package/docs/federation.md +46 -1
  244. package/docs/meta-ads.md +795 -0
  245. package/docs/upgrading.md +116 -0
  246. package/llms.txt +3 -0
  247. package/package.json +15 -3
  248. package/src/component/_generated/api.ts +14 -0
  249. package/src/component/_generated/component.ts +324 -1
  250. package/src/component/_generated/server.ts +4 -0
  251. package/src/component/constants.ts +10 -0
  252. package/src/component/convex.config.ts +9 -0
  253. package/src/component/errors.ts +5 -1
  254. package/src/component/leads.ts +517 -17
  255. package/src/component/meta.ts +1525 -0
  256. package/src/component/metaCapi.ts +921 -0
  257. package/src/component/metaFake.ts +277 -0
  258. package/src/component/metaGraph.ts +722 -0
  259. package/src/component/metaSync.ts +1164 -0
  260. package/src/component/pipeline.ts +410 -0
  261. package/src/component/schema.ts +197 -1
  262. package/src/component/secrets.ts +98 -0
  263. package/src/component/validators.ts +376 -3
  264. package/src/tracker/generated.ts +1 -1
  265. package/src/ui/seed.ts +755 -0
@@ -0,0 +1,722 @@
1
+ import { fakeMetaGraph } from "./metaFake.js";
2
+
3
+ /*
4
+ * The only module that talks to Meta. Everything else goes through the
5
+ * `MetaGraph` interface, which `RASTRO_META_FAKE=true` swaps for the
6
+ * deterministic fixtures in metaFake.ts (the example host, demos, tests).
7
+ * Tests of the real client stub `fetch`.
8
+ */
9
+
10
+ /** The Graph API version every call pins. Bump it here and nowhere else. */
11
+ export const META_GRAPH_VERSION = "v25.0";
12
+ export const META_GRAPH_ORIGIN = "https://graph.facebook.com";
13
+
14
+ const PAGE_LIMIT = 500;
15
+ const MAX_PIXELS = 200;
16
+ const MAX_PAGES = 40;
17
+ const MAX_ACCOUNTS = 2_000;
18
+ const THUMBNAIL_SIZE = 320;
19
+ const MAX_THUMBNAIL_BYTES = 2_000_000;
20
+ const CONVERSATIONS_ACTION =
21
+ "onsite_conversion.messaging_conversation_started_7d";
22
+
23
+ // Every status an ad can have, the two Meta hides by default included: an ad
24
+ // archived or deleted after it spent still has spend, a name and a status.
25
+ const ALL_STATUSES = [
26
+ "ACTIVE",
27
+ "PAUSED",
28
+ "PENDING_REVIEW",
29
+ "DISAPPROVED",
30
+ "PREAPPROVED",
31
+ "PENDING_BILLING_INFO",
32
+ "CAMPAIGN_PAUSED",
33
+ "ADSET_PAUSED",
34
+ "IN_PROCESS",
35
+ "WITH_ISSUES",
36
+ "ARCHIVED",
37
+ "DELETED",
38
+ ];
39
+
40
+ export type MetaCreativeType = "video" | "image" | "carousel" | "other";
41
+
42
+ export interface GraphAdAccount {
43
+ adAccountId: string;
44
+ name: string;
45
+ currency: string;
46
+ timezone: string;
47
+ /** Meta's `account_status`: 1 is active. */
48
+ accountStatus: number;
49
+ }
50
+
51
+ export interface GraphPixel {
52
+ pixelId: string;
53
+ name: string;
54
+ }
55
+
56
+ export interface GraphAd {
57
+ adId: string;
58
+ name: string | null;
59
+ effectiveStatus: string | null;
60
+ adsetId: string | null;
61
+ adsetName: string | null;
62
+ campaignId: string | null;
63
+ campaignName: string | null;
64
+ creativeType: MetaCreativeType | null;
65
+ thumbnailUrl: string | null;
66
+ }
67
+
68
+ /** One ad on one account-local date, spend already in integer cents. */
69
+ export interface GraphSpendRow {
70
+ adId: string;
71
+ /** The account-local date, `YYYY-MM-DD`. */
72
+ date: string;
73
+ spendCents: number;
74
+ impressions: number;
75
+ clicks: number;
76
+ /** Conversations Meta counted for the ad that day (0 when it lists none). */
77
+ conversations: number;
78
+ adName: string | null;
79
+ adsetId: string | null;
80
+ adsetName: string | null;
81
+ campaignId: string | null;
82
+ campaignName: string | null;
83
+ }
84
+
85
+ /** How close the app is to Meta's rate limits, from the response headers. */
86
+ export interface GraphUsage {
87
+ /** The highest percentage any limit header reported, 0–100+. */
88
+ pct: number;
89
+ /** Meta's own estimate of when a throttled limit lifts, if it gave one. */
90
+ regainMs: number | null;
91
+ }
92
+
93
+ /** `app`: Meta refused the app's own id or secret (the host's env). */
94
+ export type MetaErrorKind =
95
+ "token" | "throttled" | "permission" | "app" | "other";
96
+
97
+ export class MetaGraphError extends Error {
98
+ constructor(
99
+ readonly kind: MetaErrorKind,
100
+ message: string,
101
+ readonly code: number | null = null,
102
+ readonly regainMs: number | null = null,
103
+ ) {
104
+ super(message);
105
+ this.name = "MetaGraphError";
106
+ }
107
+ }
108
+
109
+ /** The Meta app Facebook Login for Business authorizes against. */
110
+ export interface MetaApp {
111
+ appId: string;
112
+ appSecret: string;
113
+ }
114
+
115
+ export interface MetaGraph {
116
+ /**
117
+ * Trades a Facebook Login for Business authorization code for its access
118
+ * token, with how long it lives when Meta says (`expires_in`). A refused
119
+ * code is a `token` error, like an expired token; a refused app id or
120
+ * secret, an `app` error.
121
+ */
122
+ exchangeCode(
123
+ app: MetaApp,
124
+ code: string,
125
+ redirectUri: string,
126
+ ): Promise<{ accessToken: string; expiresInMs: number | null }>;
127
+ /**
128
+ * What Meta knows of a token (`GET /debug_token`, with the app's token):
129
+ * its kind (`USER`, `SYSTEM_USER`, ...) and when it expires, null for
130
+ * never. A token not valid for this app is a `token` error.
131
+ */
132
+ debugToken(
133
+ app: MetaApp,
134
+ token: string,
135
+ ): Promise<{ type: string | null; expiresAt: number | null }>;
136
+ /** Trades a short-lived user token for a long-lived one (about 60 days). */
137
+ exchangeLongLived(
138
+ app: MetaApp,
139
+ token: string,
140
+ ): Promise<{ accessToken: string; expiresInMs: number | null }>;
141
+ me(token: string): Promise<{ id: string; name: string | null }>;
142
+ adAccounts(token: string): Promise<GraphAdAccount[]>;
143
+ /** The pixels (datasets) an ad account owns. */
144
+ adPixels(token: string, adAccountId: string): Promise<GraphPixel[]>;
145
+ /**
146
+ * The account's ads in any status, or only `adIds` among them. `complete`
147
+ * is false when the listing stopped at the page cap.
148
+ */
149
+ ads(
150
+ token: string,
151
+ adAccountId: string,
152
+ adIds?: string[],
153
+ ): Promise<{ ads: GraphAd[]; usage: GraphUsage; complete: boolean }>;
154
+ /**
155
+ * Spend per ad per account-local day, `since`/`until` inclusive, for ads in
156
+ * any status. `complete` is false when Meta had more pages than the cap:
157
+ * the rows are then a part of the range, never the whole of it.
158
+ */
159
+ insights(
160
+ token: string,
161
+ adAccountId: string,
162
+ since: string,
163
+ until: string,
164
+ ): Promise<{ rows: GraphSpendRow[]; usage: GraphUsage; complete: boolean }>;
165
+ /** Checks the token can reach the pixel (dataset). */
166
+ pixel(token: string, pixelId: string): Promise<{ id: string }>;
167
+ /** A creative's thumbnail, or null when it cannot be fetched. */
168
+ thumbnail(url: string): Promise<Blob | null>;
169
+ /** Sends server events to the pixel (dataset) through the Conversions API. */
170
+ conversions(
171
+ token: string,
172
+ pixelId: string,
173
+ payload: ConversionsPayload,
174
+ ): Promise<{ eventsReceived: number; fbtraceId: string | null }>;
175
+ }
176
+
177
+ /** One Conversions API event, user data already hashed by the host. */
178
+ export interface ConversionEvent {
179
+ event_name: string;
180
+ event_time: number;
181
+ event_id: string;
182
+ action_source: "chat";
183
+ user_data: { ph?: string[]; external_id?: string[]; country: string[] };
184
+ custom_data?: { value: number; currency: string };
185
+ }
186
+
187
+ export interface ConversionsPayload {
188
+ data: ConversionEvent[];
189
+ test_event_code?: string;
190
+ }
191
+
192
+ type Json = Record<string, unknown>;
193
+ type Fetch = typeof fetch;
194
+
195
+ const isRecord = (value: unknown): value is Json =>
196
+ typeof value === "object" && value !== null && !Array.isArray(value);
197
+ const text = (value: unknown): string | null =>
198
+ typeof value === "string" && value.length > 0 ? value : null;
199
+ const count = (value: unknown): number => {
200
+ const parsed = typeof value === "number" ? value : Number(value);
201
+ return Number.isFinite(parsed) && parsed > 0 ? Math.round(parsed) : 0;
202
+ };
203
+
204
+ /** Meta's `expires_in` (seconds) in milliseconds; null when absent or 0. */
205
+ function expiresInMs(value: unknown): number | null {
206
+ const seconds = Number(value);
207
+ return Number.isFinite(seconds) && seconds > 0 ? seconds * 1000 : null;
208
+ }
209
+
210
+ /** Meta's spend is a decimal string in the account currency. */
211
+ export function spendToCents(value: unknown): number {
212
+ const parsed = typeof value === "number" ? value : Number(value);
213
+ return Number.isFinite(parsed) && parsed > 0 ? Math.round(parsed * 100) : 0;
214
+ }
215
+
216
+ /** The kind of a Graph error body, by Meta's documented codes. */
217
+ export function classifyGraphError(error: Json): MetaErrorKind {
218
+ const code = typeof error.code === "number" ? error.code : null;
219
+ if (code === 190 || code === 102 || code === 463 || code === 467) {
220
+ return "token";
221
+ }
222
+ if (
223
+ code === 4 ||
224
+ code === 17 ||
225
+ code === 32 ||
226
+ code === 613 ||
227
+ (code !== null && code >= 80000 && code <= 80014)
228
+ ) {
229
+ return "throttled";
230
+ }
231
+ // 100/33: the object does not exist or the token cannot see it.
232
+ if (
233
+ code === 10 ||
234
+ (code !== null && code >= 200 && code <= 299) ||
235
+ (code === 100 && error.error_subcode === 33)
236
+ ) {
237
+ return "permission";
238
+ }
239
+ return "other";
240
+ }
241
+
242
+ function parseJsonHeader(value: string | null): unknown {
243
+ if (!value) return null;
244
+ try {
245
+ return JSON.parse(value);
246
+ } catch {
247
+ return null;
248
+ }
249
+ }
250
+
251
+ /** Reads the three usage headers Meta sends on Marketing API calls. */
252
+ export function readUsage(headers: Headers): GraphUsage {
253
+ let pct = 0;
254
+ let regainMinutes = 0;
255
+ const bump = (value: unknown) => {
256
+ const parsed = typeof value === "number" ? value : Number(value);
257
+ if (Number.isFinite(parsed)) pct = Math.max(pct, parsed);
258
+ };
259
+ const business = parseJsonHeader(headers.get("x-business-use-case-usage"));
260
+ if (isRecord(business)) {
261
+ for (const entries of Object.values(business)) {
262
+ if (!Array.isArray(entries)) continue;
263
+ for (const entry of entries) {
264
+ if (!isRecord(entry)) continue;
265
+ bump(entry.call_count);
266
+ bump(entry.total_cputime);
267
+ bump(entry.total_time);
268
+ const regain = Number(entry.estimated_time_to_regain_access);
269
+ if (Number.isFinite(regain)) {
270
+ regainMinutes = Math.max(regainMinutes, regain);
271
+ }
272
+ }
273
+ }
274
+ }
275
+ const account = parseJsonHeader(headers.get("x-ad-account-usage"));
276
+ if (isRecord(account)) bump(account.acc_id_util_pct);
277
+ const insights = parseJsonHeader(headers.get("x-fb-ads-insights-throttle"));
278
+ if (isRecord(insights)) {
279
+ bump(insights.app_id_util_pct);
280
+ bump(insights.acc_id_util_pct);
281
+ }
282
+ return {
283
+ pct,
284
+ regainMs: regainMinutes > 0 ? regainMinutes * 60_000 : null,
285
+ };
286
+ }
287
+
288
+ const mergeUsage = (left: GraphUsage, right: GraphUsage): GraphUsage => ({
289
+ pct: Math.max(left.pct, right.pct),
290
+ regainMs:
291
+ left.regainMs === null
292
+ ? right.regainMs
293
+ : right.regainMs === null
294
+ ? left.regainMs
295
+ : Math.max(left.regainMs, right.regainMs),
296
+ });
297
+
298
+ const NO_USAGE: GraphUsage = { pct: 0, regainMs: null };
299
+
300
+ /** What Meta calls the creative, reduced to the four kinds the screens use. */
301
+ export function creativeTypeOf(creative: unknown): MetaCreativeType | null {
302
+ if (!isRecord(creative)) return null;
303
+ const story = isRecord(creative.object_story_spec)
304
+ ? creative.object_story_spec
305
+ : {};
306
+ const link = isRecord(story.link_data) ? story.link_data : {};
307
+ if (Array.isArray(link.child_attachments) && link.child_attachments.length) {
308
+ return "carousel";
309
+ }
310
+ const objectType = text(creative.object_type)?.toUpperCase();
311
+ if (
312
+ objectType === "VIDEO" ||
313
+ text(creative.video_id) ||
314
+ isRecord(story.video_data)
315
+ ) {
316
+ return "video";
317
+ }
318
+ if (objectType === "PHOTO" || objectType === "SHARE" || link.picture) {
319
+ return "image";
320
+ }
321
+ return "other";
322
+ }
323
+
324
+ function toAd(raw: unknown): GraphAd | null {
325
+ if (!isRecord(raw)) return null;
326
+ const adId = text(raw.id);
327
+ if (!adId) return null;
328
+ const adset = isRecord(raw.adset) ? raw.adset : {};
329
+ const campaign = isRecord(raw.campaign) ? raw.campaign : {};
330
+ const creative = isRecord(raw.creative) ? raw.creative : null;
331
+ return {
332
+ adId,
333
+ name: text(raw.name),
334
+ effectiveStatus: text(raw.effective_status),
335
+ adsetId: text(adset.id) ?? text(raw.adset_id),
336
+ adsetName: text(adset.name),
337
+ campaignId: text(campaign.id) ?? text(raw.campaign_id),
338
+ campaignName: text(campaign.name),
339
+ creativeType: creativeTypeOf(creative),
340
+ thumbnailUrl: creative ? text(creative.thumbnail_url) : null,
341
+ };
342
+ }
343
+
344
+ function toSpendRow(raw: unknown): GraphSpendRow | null {
345
+ if (!isRecord(raw)) return null;
346
+ const adId = text(raw.ad_id);
347
+ const date = text(raw.date_start);
348
+ if (!adId || !date || !/^\d{4}-\d{2}-\d{2}$/.test(date)) return null;
349
+ let conversations = 0;
350
+ if (Array.isArray(raw.actions)) {
351
+ for (const action of raw.actions) {
352
+ if (isRecord(action) && action.action_type === CONVERSATIONS_ACTION) {
353
+ conversations = count(action.value);
354
+ }
355
+ }
356
+ }
357
+ return {
358
+ adId,
359
+ date,
360
+ spendCents: spendToCents(raw.spend),
361
+ impressions: count(raw.impressions),
362
+ clicks: count(raw.clicks),
363
+ conversations,
364
+ adName: text(raw.ad_name),
365
+ adsetId: text(raw.adset_id),
366
+ adsetName: text(raw.adset_name),
367
+ campaignId: text(raw.campaign_id),
368
+ campaignName: text(raw.campaign_name),
369
+ };
370
+ }
371
+
372
+ const AD_FIELDS = [
373
+ "id",
374
+ "name",
375
+ "effective_status",
376
+ "adset{id,name}",
377
+ "campaign{id,name}",
378
+ "creative{thumbnail_url,object_type,video_id,object_story_spec{link_data{picture,child_attachments{link}},video_data{video_id}}}",
379
+ ].join(",");
380
+
381
+ const INSIGHT_FIELDS = [
382
+ "date_start",
383
+ "ad_id",
384
+ "ad_name",
385
+ "adset_id",
386
+ "adset_name",
387
+ "campaign_id",
388
+ "campaign_name",
389
+ "spend",
390
+ "impressions",
391
+ "clicks",
392
+ "actions",
393
+ ].join(",");
394
+
395
+ /** An `act_…` id as Meta names it, whatever form the host stored. */
396
+ export function actId(adAccountId: string): string {
397
+ return adAccountId.startsWith("act_") ? adAccountId : `act_${adAccountId}`;
398
+ }
399
+
400
+ function realMetaGraph(fetchImpl: Fetch): MetaGraph {
401
+ const url = (path: string, params: Record<string, string>) => {
402
+ const target = new URL(
403
+ `${META_GRAPH_ORIGIN}/${META_GRAPH_VERSION}/${path}`,
404
+ );
405
+ for (const [key, value] of Object.entries(params)) {
406
+ target.searchParams.set(key, value);
407
+ }
408
+ return target.toString();
409
+ };
410
+
411
+ // The token rides in the Authorization header, never in a URL that could
412
+ // end up in a log.
413
+ async function call(target: string, token: string, payload?: unknown) {
414
+ const parsed = new URL(target);
415
+ if (parsed.origin !== META_GRAPH_ORIGIN) {
416
+ throw new MetaGraphError("other", "Meta returned a foreign paging URL");
417
+ }
418
+ parsed.searchParams.delete("access_token");
419
+ let response: Response;
420
+ try {
421
+ response = await fetchImpl(
422
+ parsed.toString(),
423
+ payload === undefined
424
+ ? { headers: { Authorization: `Bearer ${token}` } }
425
+ : {
426
+ method: "POST",
427
+ headers: {
428
+ Authorization: `Bearer ${token}`,
429
+ "Content-Type": "application/json",
430
+ },
431
+ body: JSON.stringify(payload),
432
+ },
433
+ );
434
+ } catch {
435
+ throw new MetaGraphError("other", "Meta Graph API unreachable");
436
+ }
437
+ const usage = readUsage(response.headers);
438
+ let body: unknown = null;
439
+ try {
440
+ body = await response.json();
441
+ } catch {
442
+ body = null;
443
+ }
444
+ if (!response.ok || (isRecord(body) && isRecord(body.error))) {
445
+ const error = isRecord(body) && isRecord(body.error) ? body.error : {};
446
+ const kind = classifyGraphError(error);
447
+ const message =
448
+ text(error.message) ?? `Meta Graph API answered ${response.status}`;
449
+ throw new MetaGraphError(
450
+ kind,
451
+ message.slice(0, 300),
452
+ typeof error.code === "number" ? error.code : null,
453
+ usage.regainMs,
454
+ );
455
+ }
456
+ return { body: isRecord(body) ? body : {}, usage };
457
+ }
458
+
459
+ // The two token exchanges, the only calls that carry the app secret, and
460
+ // the code or the short-lived token: a POST with a form body, so none of
461
+ // it sits in a URL a proxy could log. Never echoed back in an error.
462
+ async function oauth(params: Record<string, string>): Promise<Json> {
463
+ let response: Response;
464
+ try {
465
+ response = await fetchImpl(url("oauth/access_token", {}), {
466
+ method: "POST",
467
+ headers: { "Content-Type": "application/x-www-form-urlencoded" },
468
+ body: new URLSearchParams(params).toString(),
469
+ });
470
+ } catch {
471
+ throw new MetaGraphError("other", "Meta Graph API unreachable");
472
+ }
473
+ let body: unknown = null;
474
+ try {
475
+ body = await response.json();
476
+ } catch {
477
+ body = null;
478
+ }
479
+ const error = isRecord(body) && isRecord(body.error) ? body.error : null;
480
+ if (!response.ok || error) {
481
+ const code = typeof error?.code === "number" ? error.code : null;
482
+ const kind =
483
+ // 1 and 101: the client secret or the app id is wrong (the env).
484
+ code === 1 || code === 101
485
+ ? "app"
486
+ : error
487
+ ? classifyGraphError(error)
488
+ : "other";
489
+ // An OAuth failure on a code or a token (used, expired, another
490
+ // redirect) is the caller's to fix by logging in again.
491
+ throw new MetaGraphError(
492
+ kind === "other" && error?.type === "OAuthException" ? "token" : kind,
493
+ (text(error?.message) ?? `Meta answered ${response.status}`).slice(
494
+ 0,
495
+ 300,
496
+ ),
497
+ typeof error?.code === "number" ? error.code : null,
498
+ );
499
+ }
500
+ return isRecord(body) ? body : {};
501
+ }
502
+
503
+ async function pages<T>(
504
+ first: string,
505
+ token: string,
506
+ map: (raw: unknown) => T | null,
507
+ cap: number,
508
+ ) {
509
+ const items: T[] = [];
510
+ let usage = NO_USAGE;
511
+ let next: string | null = first;
512
+ for (
513
+ let page = 0;
514
+ next && page < MAX_PAGES && items.length <= cap;
515
+ page++
516
+ ) {
517
+ const result = await call(next, token);
518
+ usage = mergeUsage(usage, result.usage);
519
+ const data = Array.isArray(result.body.data) ? result.body.data : [];
520
+ for (const raw of data) {
521
+ const item = map(raw);
522
+ if (item) items.push(item);
523
+ }
524
+ const paging = isRecord(result.body.paging) ? result.body.paging : {};
525
+ next = text(paging.next);
526
+ }
527
+ // Stopped with a next page or over the cap: a part, not the whole.
528
+ return {
529
+ items: items.slice(0, cap),
530
+ usage,
531
+ complete: next === null && items.length <= cap,
532
+ };
533
+ }
534
+
535
+ return {
536
+ async exchangeCode(app, code, redirectUri) {
537
+ const body = await oauth({
538
+ client_id: app.appId,
539
+ client_secret: app.appSecret,
540
+ redirect_uri: redirectUri,
541
+ code,
542
+ });
543
+ const accessToken = text(body.access_token);
544
+ if (!accessToken) {
545
+ throw new MetaGraphError("token", "Meta returned no access token");
546
+ }
547
+ return { accessToken, expiresInMs: expiresInMs(body.expires_in) };
548
+ },
549
+
550
+ async debugToken(app, token) {
551
+ // The app's own token authorizes the inspection, in the header like
552
+ // any other; Meta takes the inspected token only in the query.
553
+ const { body } = await call(
554
+ url("debug_token", { input_token: token }),
555
+ `${app.appId}|${app.appSecret}`,
556
+ );
557
+ const data = isRecord(body.data) ? body.data : {};
558
+ if (data.is_valid === false || String(data.app_id ?? "") !== app.appId) {
559
+ throw new MetaGraphError(
560
+ "token",
561
+ "Meta says the token is not valid for this app",
562
+ );
563
+ }
564
+ const expiresAt = Number(data.expires_at);
565
+ return {
566
+ type: text(data.type),
567
+ // 0 (or nothing) is Meta's "never expires".
568
+ expiresAt:
569
+ Number.isFinite(expiresAt) && expiresAt > 0 ? expiresAt * 1000 : null,
570
+ };
571
+ },
572
+
573
+ async exchangeLongLived(app, token) {
574
+ const body = await oauth({
575
+ grant_type: "fb_exchange_token",
576
+ client_id: app.appId,
577
+ client_secret: app.appSecret,
578
+ fb_exchange_token: token,
579
+ });
580
+ const accessToken = text(body.access_token);
581
+ if (!accessToken) {
582
+ throw new MetaGraphError("token", "Meta returned no access token");
583
+ }
584
+ return { accessToken, expiresInMs: expiresInMs(body.expires_in) };
585
+ },
586
+
587
+ async me(token) {
588
+ const { body } = await call(url("me", { fields: "id,name" }), token);
589
+ const id = text(body.id);
590
+ if (!id) throw new MetaGraphError("token", "Meta did not name the token");
591
+ return { id, name: text(body.name) };
592
+ },
593
+
594
+ async adAccounts(token) {
595
+ const { items } = await pages(
596
+ url("me/adaccounts", {
597
+ fields: "id,name,currency,timezone_name,account_status",
598
+ limit: String(PAGE_LIMIT),
599
+ }),
600
+ token,
601
+ (raw) => {
602
+ if (!isRecord(raw)) return null;
603
+ const adAccountId = text(raw.id);
604
+ if (!adAccountId) return null;
605
+ return {
606
+ adAccountId: actId(adAccountId),
607
+ name: text(raw.name) ?? adAccountId,
608
+ currency: text(raw.currency)?.toUpperCase() ?? "USD",
609
+ timezone: text(raw.timezone_name) ?? "UTC",
610
+ accountStatus: count(raw.account_status),
611
+ };
612
+ },
613
+ MAX_ACCOUNTS,
614
+ );
615
+ return items;
616
+ },
617
+
618
+ async adPixels(token, adAccountId) {
619
+ const { items } = await pages(
620
+ url(`${actId(adAccountId)}/adspixels`, {
621
+ fields: "id,name",
622
+ limit: String(PAGE_LIMIT),
623
+ }),
624
+ token,
625
+ (raw) => {
626
+ if (!isRecord(raw)) return null;
627
+ const pixelId = text(raw.id);
628
+ if (!pixelId) return null;
629
+ return { pixelId, name: text(raw.name) ?? pixelId };
630
+ },
631
+ MAX_PIXELS,
632
+ );
633
+ return items;
634
+ },
635
+
636
+ async ads(token, adAccountId, adIds) {
637
+ const filtering = [
638
+ ...(adIds ? [{ field: "id", operator: "IN", value: adIds }] : []),
639
+ { field: "effective_status", operator: "IN", value: ALL_STATUSES },
640
+ ];
641
+ const { items, usage, complete } = await pages(
642
+ url(`${actId(adAccountId)}/ads`, {
643
+ fields: AD_FIELDS,
644
+ filtering: JSON.stringify(filtering),
645
+ thumbnail_width: String(THUMBNAIL_SIZE),
646
+ thumbnail_height: String(THUMBNAIL_SIZE),
647
+ limit: String(adIds ? Math.max(adIds.length, 1) : PAGE_LIMIT),
648
+ }),
649
+ token,
650
+ toAd,
651
+ adIds ? adIds.length : PAGE_LIMIT * MAX_PAGES,
652
+ );
653
+ return { ads: items, usage, complete };
654
+ },
655
+
656
+ async insights(token, adAccountId, since, until) {
657
+ const { items, usage, complete } = await pages(
658
+ url(`${actId(adAccountId)}/insights`, {
659
+ level: "ad",
660
+ time_increment: "1",
661
+ time_range: JSON.stringify({ since, until }),
662
+ fields: INSIGHT_FIELDS,
663
+ // Insights leave archived and deleted ads out unless asked.
664
+ filtering: JSON.stringify([
665
+ {
666
+ field: "ad.effective_status",
667
+ operator: "IN",
668
+ value: ALL_STATUSES,
669
+ },
670
+ ]),
671
+ limit: String(PAGE_LIMIT),
672
+ }),
673
+ token,
674
+ toSpendRow,
675
+ PAGE_LIMIT * MAX_PAGES,
676
+ );
677
+ return { rows: items, usage, complete };
678
+ },
679
+
680
+ async pixel(token, pixelId) {
681
+ const { body } = await call(url(pixelId, { fields: "id" }), token);
682
+ const id = text(body.id);
683
+ if (!id) throw new MetaGraphError("permission", "pixel not reachable");
684
+ return { id };
685
+ },
686
+
687
+ async thumbnail(target) {
688
+ let parsed: URL;
689
+ try {
690
+ parsed = new URL(target);
691
+ } catch {
692
+ return null;
693
+ }
694
+ if (parsed.protocol !== "https:") return null;
695
+ try {
696
+ const response = await fetchImpl(parsed.toString());
697
+ const type = response.headers.get("content-type") ?? "";
698
+ if (!response.ok || !type.startsWith("image/")) return null;
699
+ const blob = await response.blob();
700
+ return blob.size > 0 && blob.size <= MAX_THUMBNAIL_BYTES ? blob : null;
701
+ } catch {
702
+ return null;
703
+ }
704
+ },
705
+
706
+ async conversions(token, pixelId, payload) {
707
+ const { body } = await call(url(`${pixelId}/events`, {}), token, payload);
708
+ return {
709
+ eventsReceived: count(body.events_received),
710
+ fbtraceId: text(body.fbtrace_id),
711
+ };
712
+ },
713
+ };
714
+ }
715
+
716
+ /** The Graph client for this deployment: the fake under RASTRO_META_FAKE. */
717
+ export function metaGraph(
718
+ options: { fake?: boolean; fetch?: Fetch } = {},
719
+ ): MetaGraph {
720
+ if (options.fake) return fakeMetaGraph();
721
+ return realMetaGraph(options.fetch ?? ((...args) => fetch(...args)));
722
+ }