@iann29/rastro 0.10.9 → 0.11.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 (178) hide show
  1. package/README.md +8 -0
  2. package/agent/integration.md +45 -0
  3. package/agent/manifest.json +4 -4
  4. package/dist/client/federation.d.ts +82 -9
  5. package/dist/client/federation.d.ts.map +1 -1
  6. package/dist/client/federation.js +40 -2
  7. package/dist/client/federation.js.map +1 -1
  8. package/dist/client/index.d.ts +464 -8
  9. package/dist/client/index.d.ts.map +1 -1
  10. package/dist/client/index.js +259 -7
  11. package/dist/client/index.js.map +1 -1
  12. package/dist/component/_generated/api.d.ts +10 -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 +273 -21
  16. package/dist/component/_generated/component.d.ts.map +1 -1
  17. package/dist/component/_generated/server.d.ts +2 -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 +31 -0
  21. package/dist/component/constants.d.ts.map +1 -1
  22. package/dist/component/constants.js +31 -0
  23. package/dist/component/constants.js.map +1 -1
  24. package/dist/component/convex.config.d.ts +2 -0
  25. package/dist/component/convex.config.js +7 -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/http.d.ts.map +1 -1
  31. package/dist/component/http.js +64 -0
  32. package/dist/component/http.js.map +1 -1
  33. package/dist/component/leadAdsValidators.d.ts +1092 -0
  34. package/dist/component/leadAdsValidators.d.ts.map +1 -0
  35. package/dist/component/leadAdsValidators.js +304 -0
  36. package/dist/component/leadAdsValidators.js.map +1 -0
  37. package/dist/component/leadFormRules.d.ts +60 -0
  38. package/dist/component/leadFormRules.d.ts.map +1 -0
  39. package/dist/component/leadFormRules.js +140 -0
  40. package/dist/component/leadFormRules.js.map +1 -0
  41. package/dist/component/leadFormStore.d.ts +16 -0
  42. package/dist/component/leadFormStore.d.ts.map +1 -0
  43. package/dist/component/leadFormStore.js +92 -0
  44. package/dist/component/leadFormStore.js.map +1 -0
  45. package/dist/component/leadgenToken.d.ts +56 -0
  46. package/dist/component/leadgenToken.d.ts.map +1 -0
  47. package/dist/component/leadgenToken.js +262 -0
  48. package/dist/component/leadgenToken.js.map +1 -0
  49. package/dist/component/leads.d.ts +76 -7
  50. package/dist/component/leads.d.ts.map +1 -1
  51. package/dist/component/leads.js +112 -78
  52. package/dist/component/leads.js.map +1 -1
  53. package/dist/component/meta.d.ts.map +1 -1
  54. package/dist/component/meta.js +21 -1
  55. package/dist/component/meta.js.map +1 -1
  56. package/dist/component/metaFake.d.ts +44 -0
  57. package/dist/component/metaFake.d.ts.map +1 -1
  58. package/dist/component/metaFake.js +400 -1
  59. package/dist/component/metaFake.js.map +1 -1
  60. package/dist/component/metaGraph.d.ts +91 -0
  61. package/dist/component/metaGraph.d.ts.map +1 -1
  62. package/dist/component/metaGraph.js +217 -0
  63. package/dist/component/metaGraph.js.map +1 -1
  64. package/dist/component/metaLeads.d.ts +681 -0
  65. package/dist/component/metaLeads.d.ts.map +1 -0
  66. package/dist/component/metaLeads.js +2634 -0
  67. package/dist/component/metaLeads.js.map +1 -0
  68. package/dist/component/schema.d.ts +414 -5
  69. package/dist/component/schema.d.ts.map +1 -1
  70. package/dist/component/schema.js +169 -1
  71. package/dist/component/schema.js.map +1 -1
  72. package/dist/component/validators.d.ts +235 -11
  73. package/dist/component/validators.d.ts.map +1 -1
  74. package/dist/component/validators.js +14 -1
  75. package/dist/component/validators.js.map +1 -1
  76. package/dist/tracker/generated.d.ts +1 -1
  77. package/dist/tracker/generated.js +1 -1
  78. package/dist/ui/campaigns/CampaignsView.d.ts +5 -1
  79. package/dist/ui/campaigns/CampaignsView.d.ts.map +1 -1
  80. package/dist/ui/campaigns/CampaignsView.js.map +1 -1
  81. package/dist/ui/campaigns/Drawer.d.ts.map +1 -1
  82. package/dist/ui/campaigns/Drawer.js +8 -26
  83. package/dist/ui/campaigns/Drawer.js.map +1 -1
  84. package/dist/ui/campaigns/labels.d.ts +29 -1
  85. package/dist/ui/campaigns/labels.d.ts.map +1 -1
  86. package/dist/ui/campaigns/labels.js +77 -2
  87. package/dist/ui/campaigns/labels.js.map +1 -1
  88. package/dist/ui/campaigns/model.js +2 -2
  89. package/dist/ui/campaigns/model.js.map +1 -1
  90. package/dist/ui/forms/FirstContact.d.ts +19 -0
  91. package/dist/ui/forms/FirstContact.d.ts.map +1 -0
  92. package/dist/ui/forms/FirstContact.js +81 -0
  93. package/dist/ui/forms/FirstContact.js.map +1 -0
  94. package/dist/ui/forms/FormLead.d.ts +28 -0
  95. package/dist/ui/forms/FormLead.d.ts.map +1 -0
  96. package/dist/ui/forms/FormLead.js +199 -0
  97. package/dist/ui/forms/FormLead.js.map +1 -0
  98. package/dist/ui/forms/FormRulesDrawer.d.ts +49 -0
  99. package/dist/ui/forms/FormRulesDrawer.d.ts.map +1 -0
  100. package/dist/ui/forms/FormRulesDrawer.js +149 -0
  101. package/dist/ui/forms/FormRulesDrawer.js.map +1 -0
  102. package/dist/ui/forms/FormsView.d.ts +10 -0
  103. package/dist/ui/forms/FormsView.d.ts.map +1 -0
  104. package/dist/ui/forms/FormsView.js +215 -0
  105. package/dist/ui/forms/FormsView.js.map +1 -0
  106. package/dist/ui/forms/Icons.d.ts +46 -0
  107. package/dist/ui/forms/Icons.d.ts.map +1 -0
  108. package/dist/ui/forms/Icons.js +45 -0
  109. package/dist/ui/forms/Icons.js.map +1 -0
  110. package/dist/ui/forms/RuleRow.d.ts +37 -0
  111. package/dist/ui/forms/RuleRow.d.ts.map +1 -0
  112. package/dist/ui/forms/RuleRow.js +80 -0
  113. package/dist/ui/forms/RuleRow.js.map +1 -0
  114. package/dist/ui/forms/WhatsAppButtonGuide.d.ts +33 -0
  115. package/dist/ui/forms/WhatsAppButtonGuide.d.ts.map +1 -0
  116. package/dist/ui/forms/WhatsAppButtonGuide.js +90 -0
  117. package/dist/ui/forms/WhatsAppButtonGuide.js.map +1 -0
  118. package/dist/ui/forms/index.d.ts +11 -0
  119. package/dist/ui/forms/index.d.ts.map +1 -0
  120. package/dist/ui/forms/index.js +7 -0
  121. package/dist/ui/forms/index.js.map +1 -0
  122. package/dist/ui/forms/journey.d.ts +19 -0
  123. package/dist/ui/forms/journey.d.ts.map +1 -0
  124. package/dist/ui/forms/journey.js +54 -0
  125. package/dist/ui/forms/journey.js.map +1 -0
  126. package/dist/ui/forms/model.d.ts +239 -0
  127. package/dist/ui/forms/model.d.ts.map +1 -0
  128. package/dist/ui/forms/model.js +556 -0
  129. package/dist/ui/forms/model.js.map +1 -0
  130. package/dist/ui/forms/sample.d.ts +36 -0
  131. package/dist/ui/forms/sample.d.ts.map +1 -0
  132. package/dist/ui/forms/sample.js +444 -0
  133. package/dist/ui/forms/sample.js.map +1 -0
  134. package/dist/ui/forms/types.d.ts +454 -0
  135. package/dist/ui/forms/types.d.ts.map +1 -0
  136. package/dist/ui/forms/types.js +2 -0
  137. package/dist/ui/forms/types.js.map +1 -0
  138. package/dist/ui/index.d.ts +2 -0
  139. package/dist/ui/index.d.ts.map +1 -1
  140. package/dist/ui/index.js +4 -0
  141. package/dist/ui/index.js.map +1 -1
  142. package/dist/ui/integrations/Overlay.d.ts +3 -1
  143. package/dist/ui/integrations/Overlay.d.ts.map +1 -1
  144. package/dist/ui/integrations/Overlay.js +2 -2
  145. package/dist/ui/integrations/Overlay.js.map +1 -1
  146. package/dist/ui/portfolio/PortfolioView.d.ts +4 -1
  147. package/dist/ui/portfolio/PortfolioView.d.ts.map +1 -1
  148. package/dist/ui/portfolio/PortfolioView.js.map +1 -1
  149. package/dist/ui/sample.d.ts +3 -1
  150. package/dist/ui/sample.d.ts.map +1 -1
  151. package/dist/ui/sample.js +2 -0
  152. package/dist/ui/sample.js.map +1 -1
  153. package/dist/ui/seed.d.ts +1 -1
  154. package/dist/ui/ui.css +2301 -0
  155. package/docs/federation.md +19 -0
  156. package/docs/meta-ads.md +713 -7
  157. package/docs/upgrading.md +70 -0
  158. package/llms.txt +1 -0
  159. package/package.json +1 -1
  160. package/src/component/_generated/api.ts +10 -0
  161. package/src/component/_generated/component.ts +289 -20
  162. package/src/component/_generated/server.ts +2 -0
  163. package/src/component/constants.ts +33 -0
  164. package/src/component/convex.config.ts +7 -0
  165. package/src/component/errors.ts +3 -1
  166. package/src/component/http.ts +75 -0
  167. package/src/component/leadAdsValidators.ts +365 -0
  168. package/src/component/leadFormRules.ts +202 -0
  169. package/src/component/leadFormStore.ts +129 -0
  170. package/src/component/leadgenToken.ts +348 -0
  171. package/src/component/leads.ts +136 -84
  172. package/src/component/meta.ts +21 -1
  173. package/src/component/metaFake.ts +485 -1
  174. package/src/component/metaGraph.ts +327 -0
  175. package/src/component/metaLeads.ts +3101 -0
  176. package/src/component/schema.ts +187 -1
  177. package/src/component/validators.ts +17 -1
  178. package/src/tracker/generated.ts +1 -1
@@ -0,0 +1,348 @@
1
+ /*
2
+ * The control plane's word that Meta announced a lead: a short-lived RS256
3
+ * JWT signed with the federation issuer's key (the one the host already
4
+ * trusts for its dashboard connections), audience = the host's federation
5
+ * audience, bound to one Page and one leadgen id. A component has no
6
+ * `ctx.auth`, so the HTTP route checks it here against the issuer's published
7
+ * keys (`<issuer>/.well-known/openid-configuration` → `jwks_uri`). The token
8
+ * only says "go read this lead from Meta": the lead itself is always read
9
+ * from Meta with the Page's own token, so a replay at most repeats an
10
+ * idempotent read.
11
+ */
12
+
13
+ import { META_LEADGEN_TOKEN } from "./constants.js";
14
+
15
+ // The claim names, purpose, subject and lifetime come from
16
+ // `META_LEADGEN_TOKEN` (constants.ts, contract §12.3): the control plane signs
17
+ // with the same object.
18
+ const CLAIMS = META_LEADGEN_TOKEN.claims;
19
+
20
+ const CLOCK_SKEW_SECONDS = 60;
21
+ const JWKS_TTL_MS = 5 * 60_000;
22
+ const JWKS_REFRESH_MIN_MS = 60_000;
23
+ const MAX_TOKEN = 8_192;
24
+ const GRAPH_ID = /^\d{1,32}$/u;
25
+
26
+ export class LeadgenTokenError extends Error {
27
+ /**
28
+ * `transient`: the token could not be checked because the issuer's keys
29
+ * could not be read right now (unreachable, 5xx, 429, not JSON). The route
30
+ * answers 503 then, which the control plane retries; any other refusal is a
31
+ * 401, which it does not.
32
+ */
33
+ constructor(
34
+ readonly reason: string,
35
+ readonly transient = false,
36
+ ) {
37
+ super(reason);
38
+ this.name = "LeadgenTokenError";
39
+ }
40
+ }
41
+
42
+ type Jwk = { kty?: unknown; kid?: unknown; n?: unknown; e?: unknown };
43
+ type Fetch = typeof fetch;
44
+
45
+ const jwksCache = new Map<
46
+ string,
47
+ { keys: Jwk[]; fetchedAt: number; refreshedAt: number }
48
+ >();
49
+
50
+ function base64UrlBytes(value: string): Uint8Array<ArrayBuffer> {
51
+ if (!/^[A-Za-z0-9_-]*$/u.test(value)) {
52
+ throw new LeadgenTokenError("token is not base64url");
53
+ }
54
+ const padded = value.replace(/-/g, "+").replace(/_/g, "/");
55
+ const binary = atob(padded + "=".repeat((4 - (padded.length % 4)) % 4));
56
+ const bytes = new Uint8Array(binary.length);
57
+ for (let index = 0; index < binary.length; index += 1) {
58
+ bytes[index] = binary.charCodeAt(index);
59
+ }
60
+ return bytes;
61
+ }
62
+
63
+ function jsonPart(value: string): Record<string, unknown> {
64
+ let parsed: unknown;
65
+ try {
66
+ parsed = JSON.parse(new TextDecoder().decode(base64UrlBytes(value)));
67
+ } catch (error) {
68
+ if (error instanceof LeadgenTokenError) throw error;
69
+ throw new LeadgenTokenError("token part is not JSON");
70
+ }
71
+ if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) {
72
+ throw new LeadgenTokenError("token part is not an object");
73
+ }
74
+ return parsed as Record<string, unknown>;
75
+ }
76
+
77
+ const trimSlash = (value: string) => value.replace(/\/+$/u, "");
78
+
79
+ /**
80
+ * The production dashboard's federation issuer: what the leadgen route trusts
81
+ * when the host passes no `RASTRO_FEDERATION_ISSUER`, as a host's federation
82
+ * API does. The audience has no default: it is the host's own functions URL.
83
+ */
84
+ export const DEFAULT_FEDERATION_ISSUER =
85
+ "https://site.api.amagerastro.com/federation";
86
+
87
+ /**
88
+ * The issuer and audience the leadgen route checks forwards against, from the
89
+ * component's env. `audience` null means the route refuses every forward
90
+ * (`FEDERATION_NOT_CONFIGURED`), so a Page must not be linked or routed here.
91
+ */
92
+ export function leadgenTrust(env: {
93
+ RASTRO_FEDERATION_ISSUER?: string;
94
+ RASTRO_FEDERATION_AUDIENCE?: string;
95
+ }): { issuer: string; audience: string | null } {
96
+ return {
97
+ issuer:
98
+ trimSlash(env.RASTRO_FEDERATION_ISSUER?.trim() ?? "") ||
99
+ DEFAULT_FEDERATION_ISSUER,
100
+ audience: trimSlash(env.RASTRO_FEDERATION_AUDIENCE?.trim() ?? "") || null,
101
+ };
102
+ }
103
+
104
+ function sameOriginHttps(base: string, target: string): boolean {
105
+ try {
106
+ const from = new URL(base);
107
+ const to = new URL(target);
108
+ const local = to.hostname === "localhost" || to.hostname === "127.0.0.1";
109
+ return (
110
+ from.origin === to.origin &&
111
+ (to.protocol === "https:" || (to.protocol === "http:" && local))
112
+ );
113
+ } catch {
114
+ return false;
115
+ }
116
+ }
117
+
118
+ async function fetchJson(fetchImpl: Fetch, target: string): Promise<unknown> {
119
+ let response: Response;
120
+ try {
121
+ response = await fetchImpl(target, {
122
+ headers: { Accept: "application/json" },
123
+ });
124
+ } catch {
125
+ throw new LeadgenTokenError("the issuer is unreachable", true);
126
+ }
127
+ if (!response.ok) {
128
+ throw new LeadgenTokenError(
129
+ `the issuer answered ${response.status}`,
130
+ response.status >= 500 || response.status === 429,
131
+ );
132
+ }
133
+ try {
134
+ return await response.json();
135
+ } catch {
136
+ throw new LeadgenTokenError(
137
+ "the issuer answered something else than JSON",
138
+ true,
139
+ );
140
+ }
141
+ }
142
+
143
+ /**
144
+ * The issuer's signing keys, cached for five minutes; `fetched` says whether
145
+ * they were read from the issuer just now (a forced refresh within a minute
146
+ * of the last read answers from the cache).
147
+ */
148
+ async function issuerKeys(
149
+ issuer: string,
150
+ fetchImpl: Fetch,
151
+ now: number,
152
+ refresh: boolean,
153
+ ): Promise<{ keys: Jwk[]; fetched: boolean }> {
154
+ const cached = jwksCache.get(issuer);
155
+ if (
156
+ cached &&
157
+ now - cached.fetchedAt < JWKS_TTL_MS &&
158
+ !(refresh && now - cached.refreshedAt >= JWKS_REFRESH_MIN_MS)
159
+ ) {
160
+ return { keys: cached.keys, fetched: false };
161
+ }
162
+ const discovery = await fetchJson(
163
+ fetchImpl,
164
+ `${issuer}/.well-known/openid-configuration`,
165
+ );
166
+ const jwksUri =
167
+ discovery &&
168
+ typeof discovery === "object" &&
169
+ typeof (discovery as { jwks_uri?: unknown }).jwks_uri === "string"
170
+ ? (discovery as { jwks_uri: string }).jwks_uri
171
+ : `${issuer}/jwks.json`;
172
+ if (!sameOriginHttps(issuer, jwksUri)) {
173
+ throw new LeadgenTokenError("the issuer's jwks_uri is not its own");
174
+ }
175
+ const jwks = await fetchJson(fetchImpl, jwksUri);
176
+ const keys =
177
+ jwks &&
178
+ typeof jwks === "object" &&
179
+ Array.isArray((jwks as { keys?: unknown }).keys)
180
+ ? ((jwks as { keys: unknown[] }).keys.filter(
181
+ (key) => key && typeof key === "object",
182
+ ) as Jwk[])
183
+ : [];
184
+ jwksCache.set(issuer, { keys, fetchedAt: now, refreshedAt: now });
185
+ return { keys, fetched: true };
186
+ }
187
+
188
+ /** For tests: forget the cached keys. */
189
+ export function clearLeadgenKeyCache(): void {
190
+ jwksCache.clear();
191
+ }
192
+
193
+ export interface LeadgenTokenClaims {
194
+ connectionId: string;
195
+ organizationId: string;
196
+ version: number;
197
+ pageId: string;
198
+ leadgenId: string;
199
+ }
200
+
201
+ /**
202
+ * Checks a forwarding token: RS256 under one of the issuer's keys, the
203
+ * issuer and audience the host trusts, alive and short-lived, for the
204
+ * leadgen purpose, and bound to exactly this Page and leadgen id. Throws a
205
+ * `LeadgenTokenError` (no secret in its reason) when anything is off.
206
+ */
207
+ export async function verifyLeadgenToken(
208
+ token: string,
209
+ expected: {
210
+ issuer: string;
211
+ audience: string;
212
+ pageId: string;
213
+ leadgenId: string;
214
+ },
215
+ options: { now?: number; fetch?: Fetch } = {},
216
+ ): Promise<LeadgenTokenClaims> {
217
+ const now = options.now ?? Date.now();
218
+ const fetchImpl = options.fetch ?? ((...args) => fetch(...args));
219
+ const issuer = trimSlash(expected.issuer.trim());
220
+ const audience = trimSlash(expected.audience.trim());
221
+ if (!issuer || !audience) {
222
+ throw new LeadgenTokenError("the host has no federation issuer");
223
+ }
224
+ if (token.length > MAX_TOKEN) throw new LeadgenTokenError("token too long");
225
+ const parts = token.split(".");
226
+ if (parts.length !== 3) throw new LeadgenTokenError("token is not a JWT");
227
+ const [headerPart, payloadPart, signaturePart] = parts as [
228
+ string,
229
+ string,
230
+ string,
231
+ ];
232
+ const header = jsonPart(headerPart);
233
+ if (header.alg !== "RS256") {
234
+ throw new LeadgenTokenError("token is not RS256");
235
+ }
236
+ const claims = jsonPart(payloadPart);
237
+
238
+ // The cheap checks first: a token for something else costs no key fetch.
239
+ const seconds = Math.floor(now / 1000);
240
+ const aud = claims.aud;
241
+ const audiences = (Array.isArray(aud) ? aud : [aud]).map((value) =>
242
+ typeof value === "string" ? trimSlash(value) : "",
243
+ );
244
+ const exp = claims.exp;
245
+ const iat = claims.iat;
246
+ if (typeof claims.iss !== "string" || trimSlash(claims.iss) !== issuer) {
247
+ throw new LeadgenTokenError("token issuer is not trusted");
248
+ }
249
+ if (!audiences.includes(audience)) {
250
+ throw new LeadgenTokenError("token audience is not this host");
251
+ }
252
+ if (typeof exp !== "number" || exp + CLOCK_SKEW_SECONDS < seconds) {
253
+ throw new LeadgenTokenError("token expired");
254
+ }
255
+ if (
256
+ typeof iat !== "number" ||
257
+ iat - CLOCK_SKEW_SECONDS > seconds ||
258
+ exp - iat > META_LEADGEN_TOKEN.maxLifetimeSeconds
259
+ ) {
260
+ throw new LeadgenTokenError("token lifetime is not a forwarding one");
261
+ }
262
+ if (
263
+ claims[CLAIMS.purpose] !== META_LEADGEN_TOKEN.purpose ||
264
+ claims.sub !== META_LEADGEN_TOKEN.subject
265
+ ) {
266
+ throw new LeadgenTokenError("token is not for a leadgen");
267
+ }
268
+ const organizationId = claims[CLAIMS.organizationId];
269
+ const version = claims[CLAIMS.version];
270
+ if (
271
+ typeof organizationId !== "string" ||
272
+ organizationId.length === 0 ||
273
+ organizationId.length > 128 ||
274
+ typeof version !== "number" ||
275
+ !Number.isSafeInteger(version) ||
276
+ version < 0
277
+ ) {
278
+ throw new LeadgenTokenError("token names no organization");
279
+ }
280
+ const connectionId = claims[CLAIMS.connectionId];
281
+ if (
282
+ typeof connectionId !== "string" ||
283
+ connectionId.length === 0 ||
284
+ connectionId.length > 128
285
+ ) {
286
+ throw new LeadgenTokenError("token names no connection");
287
+ }
288
+ if (
289
+ claims[CLAIMS.pageId] !== expected.pageId ||
290
+ claims[CLAIMS.leadgenId] !== expected.leadgenId ||
291
+ !GRAPH_ID.test(expected.pageId) ||
292
+ !GRAPH_ID.test(expected.leadgenId)
293
+ ) {
294
+ throw new LeadgenTokenError("token is for another lead");
295
+ }
296
+
297
+ const kid = typeof header.kid === "string" ? header.kid : null;
298
+ const pick = (keys: Jwk[]) =>
299
+ keys.filter(
300
+ (key) =>
301
+ key.kty === "RSA" &&
302
+ typeof key.n === "string" &&
303
+ typeof key.e === "string" &&
304
+ (kid === null || key.kid === undefined || key.kid === kid),
305
+ );
306
+ let candidates = pick((await issuerKeys(issuer, fetchImpl, now, false)).keys);
307
+ if (candidates.length === 0) {
308
+ // A rotated key: read the issuer again (at most once a minute).
309
+ const again = await issuerKeys(issuer, fetchImpl, now, true);
310
+ candidates = pick(again.keys);
311
+ if (candidates.length === 0) {
312
+ // Keys read a moment ago may predate the rotation: transient (503,
313
+ // retried) until the issuer has been read again.
314
+ throw new LeadgenTokenError(
315
+ again.fetched
316
+ ? "no issuer key matches the token"
317
+ : "no issuer key matches the token yet",
318
+ !again.fetched,
319
+ );
320
+ }
321
+ }
322
+ const data = new TextEncoder().encode(`${headerPart}.${payloadPart}`);
323
+ const signature = base64UrlBytes(signaturePart);
324
+ for (const jwk of candidates) {
325
+ let key: CryptoKey;
326
+ try {
327
+ key = await crypto.subtle.importKey(
328
+ "jwk",
329
+ { kty: "RSA", n: jwk.n as string, e: jwk.e as string, ext: true },
330
+ { name: "RSASSA-PKCS1-v1_5", hash: "SHA-256" },
331
+ false,
332
+ ["verify"],
333
+ );
334
+ } catch {
335
+ continue;
336
+ }
337
+ if (await crypto.subtle.verify("RSASSA-PKCS1-v1_5", key, signature, data)) {
338
+ return {
339
+ connectionId,
340
+ organizationId,
341
+ version,
342
+ pageId: expected.pageId,
343
+ leadgenId: expected.leadgenId,
344
+ };
345
+ }
346
+ }
347
+ throw new LeadgenTokenError("token signature is invalid");
348
+ }
@@ -19,6 +19,7 @@ import {
19
19
  MAX_SITE_AD_ACCOUNTS,
20
20
  MAX_AD_SPEND_ROWS,
21
21
  MAX_SPEND_SUM_ROWS,
22
+ PAID_LEADS_ORIGIN,
22
23
  } from "./constants.js";
23
24
  import { fail } from "./errors.js";
24
25
  import {
@@ -32,6 +33,7 @@ import {
32
33
  parseMetaUserData,
33
34
  removeLeadConversions,
34
35
  } from "./metaCapi.js";
36
+ import { forgetFormLead, formLeadDetail } from "./leadFormStore.js";
35
37
  import { cleanString, isValidMoney, sanitizeOpaqueId } from "./sanitize.js";
36
38
  import {
37
39
  leadAdRowValidator,
@@ -71,6 +73,20 @@ const MAX_NAME = 200;
71
73
  export const AD_ORIGIN = "ctwa_ad";
72
74
  /** The bucket for a lead the channel gave no entry point for. */
73
75
  export const NO_ORIGIN = "none";
76
+ /**
77
+ * A Meta instant form's leads (since 0.11.0): `lead_form` when an ad brought
78
+ * them, so they count as paid next to `ctwa_ad`; `lead_form_organic` when the
79
+ * form was filled without an ad.
80
+ */
81
+ export const LEAD_FORM_ORIGIN = "lead_form";
82
+ export const LEAD_FORM_ORGANIC_ORIGIN = "lead_form_organic";
83
+ /** The origins spend buys: what `bySite[].paid` counts. */
84
+ const PAID_ORIGINS: ReadonlySet<string> = new Set([
85
+ AD_ORIGIN,
86
+ LEAD_FORM_ORIGIN,
87
+ ]);
88
+ /** `leads.daily`'s `origin` for every paid origin together. */
89
+ const PAID_SCOPE = PAID_LEADS_ORIGIN;
74
90
 
75
91
  type LeadAd = Infer<typeof leadAdValidator>;
76
92
  type Counters = {
@@ -149,6 +165,9 @@ function sanitizeEntryPoint(value: string | undefined): string | undefined {
149
165
  }
150
166
 
151
167
  function originKeyFor(entryPoint: string | undefined, ad: LeadAd | undefined) {
168
+ if (entryPoint === LEAD_FORM_ORIGIN) {
169
+ return ad?.adId ? LEAD_FORM_ORIGIN : LEAD_FORM_ORGANIC_ORIGIN;
170
+ }
152
171
  if (ad || entryPoint === AD_ORIGIN) return AD_ORIGIN;
153
172
  return entryPoint ?? NO_ORIGIN;
154
173
  }
@@ -156,7 +175,7 @@ function originKeyFor(entryPoint: string | undefined, ad: LeadAd | undefined) {
156
175
  async function bumpRollup(
157
176
  ctx: MutationCtx,
158
177
  lead: Pick<Doc<"leads">, "siteId" | "dayStart">,
159
- dimension: "origin" | "ad",
178
+ dimension: "origin" | "ad" | "form",
160
179
  key: string,
161
180
  delta: Counters,
162
181
  ) {
@@ -194,11 +213,12 @@ async function bumpRollup(
194
213
  /** Applies one lead's change to every rollup row it is counted in. */
195
214
  async function bumpLead(
196
215
  ctx: MutationCtx,
197
- lead: Pick<Doc<"leads">, "siteId" | "dayStart" | "originKey" | "ad">,
216
+ lead: Pick<Doc<"leads">, "siteId" | "dayStart" | "originKey" | "ad" | "form">,
198
217
  delta: Counters,
199
218
  ) {
200
219
  await bumpRollup(ctx, lead, "origin", lead.originKey, delta);
201
220
  if (lead.ad?.adId) await bumpRollup(ctx, lead, "ad", lead.ad.adId, delta);
221
+ if (lead.form) await bumpRollup(ctx, lead, "form", lead.form.formId, delta);
202
222
  }
203
223
 
204
224
  async function findLead(ctx: QueryCtx, siteId: Id<"sites">, leadKey: string) {
@@ -236,86 +256,108 @@ export const record = mutation({
236
256
  meta: v.optional(v.any()),
237
257
  },
238
258
  returns: v.object({ leadId: v.id("leads"), created: v.boolean() }),
239
- handler: async (ctx, args) => {
240
- const site = await ctx.db.get("sites", args.siteId);
241
- if (!site) fail("NOT_FOUND", "site not found");
242
- const now = Date.now();
243
- const leadKey = opaque(args.leadKey, "leadKey");
244
- const meta = parseMetaUserData(args.meta) ?? undefined;
245
- const label = optionalText(args.label, MAX_LABEL);
246
- const visitorId =
247
- args.visitorId === undefined
248
- ? undefined
249
- : opaque(args.visitorId, "visitorId");
250
- const existing = await findLead(ctx, args.siteId, leadKey);
251
- if (existing) {
252
- const patch: Partial<Doc<"leads">> = {};
253
- if (label && label !== existing.label) patch.label = label;
254
- if (visitorId && !existing.visitorId) patch.visitorId = visitorId;
255
- // The column is where the lead is now, so a later call may move it.
256
- const moved = await enterColumn(
257
- ctx,
258
- existing,
259
- args.column,
260
- eventTime(args.occurredAt, now),
261
- meta,
262
- );
263
- if (moved) Object.assign(patch, moved);
264
- if (Object.keys(patch).length > 0) {
265
- await ctx.db.patch("leads", existing._id, { ...patch, updatedAt: now });
266
- }
267
- return { leadId: existing._id, created: false };
268
- }
269
- const entryPoint = sanitizeEntryPoint(args.entryPoint);
270
- const ad = sanitizeAd(args.ad);
271
- const firstAt = eventTime(args.occurredAt, now);
272
- const lead = {
273
- siteId: args.siteId,
274
- leadKey,
275
- channel: args.channel,
276
- ...(entryPoint ? { entryPoint } : {}),
277
- originKey: originKeyFor(entryPoint, ad),
278
- ...(ad ? { ad } : {}),
279
- ...(label ? { label } : {}),
280
- ...(visitorId ? { visitorId } : {}),
281
- stage: "lead" as const,
282
- revenueCents: 0,
283
- firstAt,
284
- dayStart: Math.floor(firstAt / DAY_MS) * DAY_MS,
285
- stageAt: firstAt,
286
- updatedAt: now,
287
- };
288
- const leadId = await ctx.db.insert("leads", lead);
289
- const placed = await enterColumn(
259
+ handler: async (ctx, args) => await recordLeadCore(ctx, args),
260
+ });
261
+
262
+ /**
263
+ * `record`'s body, shared with the Lead Ads ingestion (metaLeads.ts), which
264
+ * also files the lead under its instant form (`form`).
265
+ */
266
+ export async function recordLeadCore(
267
+ ctx: MutationCtx,
268
+ args: {
269
+ siteId: Id<"sites">;
270
+ leadKey: string;
271
+ channel: Doc<"leads">["channel"];
272
+ entryPoint?: string | undefined;
273
+ ad?: LeadAd | undefined;
274
+ label?: string | undefined;
275
+ visitorId?: string | undefined;
276
+ column?: string | undefined;
277
+ occurredAt: number;
278
+ meta?: unknown;
279
+ form?: { formId: string; pageId: string } | undefined;
280
+ },
281
+ ): Promise<{ leadId: Id<"leads">; created: boolean }> {
282
+ const site = await ctx.db.get("sites", args.siteId);
283
+ if (!site) fail("NOT_FOUND", "site not found");
284
+ const now = Date.now();
285
+ const leadKey = opaque(args.leadKey, "leadKey");
286
+ const meta = parseMetaUserData(args.meta) ?? undefined;
287
+ const label = optionalText(args.label, MAX_LABEL);
288
+ const visitorId =
289
+ args.visitorId === undefined
290
+ ? undefined
291
+ : opaque(args.visitorId, "visitorId");
292
+ const existing = await findLead(ctx, args.siteId, leadKey);
293
+ if (existing) {
294
+ const patch: Partial<Doc<"leads">> = {};
295
+ if (label && label !== existing.label) patch.label = label;
296
+ if (visitorId && !existing.visitorId) patch.visitorId = visitorId;
297
+ // The column is where the lead is now, so a later call may move it.
298
+ const moved = await enterColumn(
290
299
  ctx,
291
- { _id: leadId, siteId: args.siteId, column: undefined },
300
+ existing,
292
301
  args.column,
293
- firstAt,
302
+ eventTime(args.occurredAt, now),
294
303
  meta,
295
304
  );
296
- if (placed) await ctx.db.patch("leads", leadId, placed);
297
- await ctx.db.insert("leadEvents", {
298
- siteId: args.siteId,
299
- leadId,
300
- eventId: `created:${leadKey}`,
301
- type: "created",
302
- at: firstAt,
303
- });
304
- await bumpLead(ctx, lead, {
305
- leads: 1,
306
- qualified: 0,
307
- won: 0,
308
- revenueCents: 0,
309
- });
310
- await enqueueNewLead(ctx, {
311
- siteId: args.siteId,
312
- leadId,
313
- eventTime: firstAt,
314
- meta,
315
- });
316
- return { leadId, created: true };
317
- },
318
- });
305
+ if (moved) Object.assign(patch, moved);
306
+ if (Object.keys(patch).length > 0) {
307
+ await ctx.db.patch("leads", existing._id, { ...patch, updatedAt: now });
308
+ }
309
+ return { leadId: existing._id, created: false };
310
+ }
311
+ const entryPoint = sanitizeEntryPoint(args.entryPoint);
312
+ const ad = sanitizeAd(args.ad);
313
+ const firstAt = eventTime(args.occurredAt, now);
314
+ const lead = {
315
+ siteId: args.siteId,
316
+ leadKey,
317
+ channel: args.channel,
318
+ ...(entryPoint ? { entryPoint } : {}),
319
+ originKey: originKeyFor(entryPoint, ad),
320
+ ...(ad ? { ad } : {}),
321
+ ...(label ? { label } : {}),
322
+ ...(visitorId ? { visitorId } : {}),
323
+ ...(args.form ? { form: args.form } : {}),
324
+ stage: "lead" as const,
325
+ revenueCents: 0,
326
+ firstAt,
327
+ dayStart: Math.floor(firstAt / DAY_MS) * DAY_MS,
328
+ stageAt: firstAt,
329
+ updatedAt: now,
330
+ };
331
+ const leadId = await ctx.db.insert("leads", lead);
332
+ const placed = await enterColumn(
333
+ ctx,
334
+ { _id: leadId, siteId: args.siteId, column: undefined },
335
+ args.column,
336
+ firstAt,
337
+ meta,
338
+ );
339
+ if (placed) await ctx.db.patch("leads", leadId, placed);
340
+ await ctx.db.insert("leadEvents", {
341
+ siteId: args.siteId,
342
+ leadId,
343
+ eventId: `created:${leadKey}`,
344
+ type: "created",
345
+ at: firstAt,
346
+ });
347
+ await bumpLead(ctx, lead, {
348
+ leads: 1,
349
+ qualified: 0,
350
+ won: 0,
351
+ revenueCents: 0,
352
+ });
353
+ await enqueueNewLead(ctx, {
354
+ siteId: args.siteId,
355
+ leadId,
356
+ eventTime: firstAt,
357
+ meta,
358
+ });
359
+ return { leadId, created: true };
360
+ }
319
361
 
320
362
  /**
321
363
  * Moves a lead through the host's pipeline, idempotent by `eventId`. A lead the
@@ -488,6 +530,7 @@ export const remove = mutation({
488
530
  revenueCents: lead.stage === "won" ? -lead.revenueCents : 0,
489
531
  });
490
532
  await forgetLeadColumns(ctx, lead);
533
+ await forgetFormLead(ctx, lead._id);
491
534
  await ctx.db.delete("leads", lead._id);
492
535
  return { removed: true };
493
536
  },
@@ -835,7 +878,7 @@ export const report = query({
835
878
  )) {
836
879
  add(totals, row);
837
880
  add(siteCounters, row);
838
- if (row.key === AD_ORIGIN) add(paidCounters, row);
881
+ if (PAID_ORIGINS.has(row.key)) add(paidCounters, row);
839
882
  const counters = origins.get(row.key) ?? zero();
840
883
  add(counters, row);
841
884
  origins.set(row.key, counters);
@@ -1080,8 +1123,9 @@ export const report = query({
1080
1123
  /**
1081
1124
  * The lead funnel per cohort day, for sparklines and the sales × spend chart:
1082
1125
  * every UTC day in the range, empty days included. With `adId`, only that
1083
- * ad's leads; with `origin`, only that origin's (`ctwa_ad` for the leads the
1084
- * ads bought). Spend is the day's Meta spend for the same scope, null where
1126
+ * ad's leads; with `origin`, only that origin's (`ctwa_ad` for the
1127
+ * click-to-WhatsApp ads, `paid` for every lead an ad bought: `ctwa_ad` and
1128
+ * `lead_form`). Spend is the day's Meta spend for the same scope, null where
1085
1129
  * no synced ad account covers it, for an organic origin, or when the sites'
1086
1130
  * accounts bill in more than one currency.
1087
1131
  */
@@ -1112,7 +1156,14 @@ export const daily = query({
1112
1156
  }
1113
1157
  const adId =
1114
1158
  args.adId === undefined ? undefined : opaque(args.adId, "adId");
1159
+ const paidScope = args.origin === PAID_SCOPE;
1115
1160
  const key = adId ?? args.origin;
1161
+ const keep = (rowKey: string) =>
1162
+ key === undefined ||
1163
+ (paidScope ? PAID_ORIGINS.has(rowKey) : rowKey === key);
1164
+ // Spend buys the ad leads: an organic origin has none to show.
1165
+ const spendScope =
1166
+ args.origin === undefined || args.origin === AD_ORIGIN || paidScope;
1116
1167
  const days = new Map<number, Counters>();
1117
1168
  for (let day = first; day <= lastDay; day += DAY_MS) days.set(day, zero());
1118
1169
  const budget = { rows: MAX_LEAD_REPORT_ROWS };
@@ -1132,12 +1183,11 @@ export const daily = query({
1132
1183
  first,
1133
1184
  last,
1134
1185
  )) {
1135
- if (key !== undefined && row.key !== key) continue;
1186
+ if (!keep(row.key)) continue;
1136
1187
  const counters = days.get(row.dayStart);
1137
1188
  if (counters) add(counters, row);
1138
1189
  }
1139
- // Spend buys the ad leads: an organic origin has none to show.
1140
- if (args.origin !== undefined && args.origin !== AD_ORIGIN) continue;
1190
+ if (!spendScope) continue;
1141
1191
  const sources = await spendSources(ctx, siteId);
1142
1192
  if (sources.accounts.size === 0) continue;
1143
1193
  let rows: Array<{
@@ -1313,6 +1363,8 @@ export const journey = query({
1313
1363
  }
1314
1364
  : null,
1315
1365
  visitorId: lead.visitorId ?? null,
1366
+ // Since 0.11.0: the instant form's answers, origin and path.
1367
+ ...(lead.form ? { form: await formLeadDetail(ctx, lead) } : {}),
1316
1368
  events: events.map((event) => ({
1317
1369
  type: event.type,
1318
1370
  at: event.at,