@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.
- package/README.md +8 -0
- package/agent/integration.md +45 -0
- package/agent/manifest.json +4 -4
- package/dist/client/federation.d.ts +82 -9
- package/dist/client/federation.d.ts.map +1 -1
- package/dist/client/federation.js +40 -2
- package/dist/client/federation.js.map +1 -1
- package/dist/client/index.d.ts +464 -8
- package/dist/client/index.d.ts.map +1 -1
- package/dist/client/index.js +259 -7
- package/dist/client/index.js.map +1 -1
- package/dist/component/_generated/api.d.ts +10 -0
- package/dist/component/_generated/api.d.ts.map +1 -1
- package/dist/component/_generated/api.js.map +1 -1
- package/dist/component/_generated/component.d.ts +273 -21
- package/dist/component/_generated/component.d.ts.map +1 -1
- package/dist/component/_generated/server.d.ts +2 -0
- package/dist/component/_generated/server.d.ts.map +1 -1
- package/dist/component/_generated/server.js.map +1 -1
- package/dist/component/constants.d.ts +31 -0
- package/dist/component/constants.d.ts.map +1 -1
- package/dist/component/constants.js +31 -0
- package/dist/component/constants.js.map +1 -1
- package/dist/component/convex.config.d.ts +2 -0
- package/dist/component/convex.config.js +7 -0
- package/dist/component/convex.config.js.map +1 -1
- package/dist/component/errors.d.ts +1 -1
- package/dist/component/errors.d.ts.map +1 -1
- package/dist/component/errors.js.map +1 -1
- package/dist/component/http.d.ts.map +1 -1
- package/dist/component/http.js +64 -0
- package/dist/component/http.js.map +1 -1
- package/dist/component/leadAdsValidators.d.ts +1092 -0
- package/dist/component/leadAdsValidators.d.ts.map +1 -0
- package/dist/component/leadAdsValidators.js +304 -0
- package/dist/component/leadAdsValidators.js.map +1 -0
- package/dist/component/leadFormRules.d.ts +60 -0
- package/dist/component/leadFormRules.d.ts.map +1 -0
- package/dist/component/leadFormRules.js +140 -0
- package/dist/component/leadFormRules.js.map +1 -0
- package/dist/component/leadFormStore.d.ts +16 -0
- package/dist/component/leadFormStore.d.ts.map +1 -0
- package/dist/component/leadFormStore.js +92 -0
- package/dist/component/leadFormStore.js.map +1 -0
- package/dist/component/leadgenToken.d.ts +56 -0
- package/dist/component/leadgenToken.d.ts.map +1 -0
- package/dist/component/leadgenToken.js +262 -0
- package/dist/component/leadgenToken.js.map +1 -0
- package/dist/component/leads.d.ts +76 -7
- package/dist/component/leads.d.ts.map +1 -1
- package/dist/component/leads.js +112 -78
- package/dist/component/leads.js.map +1 -1
- package/dist/component/meta.d.ts.map +1 -1
- package/dist/component/meta.js +21 -1
- package/dist/component/meta.js.map +1 -1
- package/dist/component/metaFake.d.ts +44 -0
- package/dist/component/metaFake.d.ts.map +1 -1
- package/dist/component/metaFake.js +400 -1
- package/dist/component/metaFake.js.map +1 -1
- package/dist/component/metaGraph.d.ts +91 -0
- package/dist/component/metaGraph.d.ts.map +1 -1
- package/dist/component/metaGraph.js +217 -0
- package/dist/component/metaGraph.js.map +1 -1
- package/dist/component/metaLeads.d.ts +681 -0
- package/dist/component/metaLeads.d.ts.map +1 -0
- package/dist/component/metaLeads.js +2634 -0
- package/dist/component/metaLeads.js.map +1 -0
- package/dist/component/schema.d.ts +414 -5
- package/dist/component/schema.d.ts.map +1 -1
- package/dist/component/schema.js +169 -1
- package/dist/component/schema.js.map +1 -1
- package/dist/component/validators.d.ts +235 -11
- package/dist/component/validators.d.ts.map +1 -1
- package/dist/component/validators.js +14 -1
- package/dist/component/validators.js.map +1 -1
- package/dist/tracker/generated.d.ts +1 -1
- package/dist/tracker/generated.js +1 -1
- package/dist/ui/campaigns/CampaignsView.d.ts +5 -1
- package/dist/ui/campaigns/CampaignsView.d.ts.map +1 -1
- package/dist/ui/campaigns/CampaignsView.js.map +1 -1
- package/dist/ui/campaigns/Drawer.d.ts.map +1 -1
- package/dist/ui/campaigns/Drawer.js +8 -26
- package/dist/ui/campaigns/Drawer.js.map +1 -1
- package/dist/ui/campaigns/labels.d.ts +29 -1
- package/dist/ui/campaigns/labels.d.ts.map +1 -1
- package/dist/ui/campaigns/labels.js +77 -2
- package/dist/ui/campaigns/labels.js.map +1 -1
- package/dist/ui/campaigns/model.js +2 -2
- package/dist/ui/campaigns/model.js.map +1 -1
- package/dist/ui/forms/FirstContact.d.ts +19 -0
- package/dist/ui/forms/FirstContact.d.ts.map +1 -0
- package/dist/ui/forms/FirstContact.js +81 -0
- package/dist/ui/forms/FirstContact.js.map +1 -0
- package/dist/ui/forms/FormLead.d.ts +28 -0
- package/dist/ui/forms/FormLead.d.ts.map +1 -0
- package/dist/ui/forms/FormLead.js +199 -0
- package/dist/ui/forms/FormLead.js.map +1 -0
- package/dist/ui/forms/FormRulesDrawer.d.ts +49 -0
- package/dist/ui/forms/FormRulesDrawer.d.ts.map +1 -0
- package/dist/ui/forms/FormRulesDrawer.js +149 -0
- package/dist/ui/forms/FormRulesDrawer.js.map +1 -0
- package/dist/ui/forms/FormsView.d.ts +10 -0
- package/dist/ui/forms/FormsView.d.ts.map +1 -0
- package/dist/ui/forms/FormsView.js +215 -0
- package/dist/ui/forms/FormsView.js.map +1 -0
- package/dist/ui/forms/Icons.d.ts +46 -0
- package/dist/ui/forms/Icons.d.ts.map +1 -0
- package/dist/ui/forms/Icons.js +45 -0
- package/dist/ui/forms/Icons.js.map +1 -0
- package/dist/ui/forms/RuleRow.d.ts +37 -0
- package/dist/ui/forms/RuleRow.d.ts.map +1 -0
- package/dist/ui/forms/RuleRow.js +80 -0
- package/dist/ui/forms/RuleRow.js.map +1 -0
- package/dist/ui/forms/WhatsAppButtonGuide.d.ts +33 -0
- package/dist/ui/forms/WhatsAppButtonGuide.d.ts.map +1 -0
- package/dist/ui/forms/WhatsAppButtonGuide.js +90 -0
- package/dist/ui/forms/WhatsAppButtonGuide.js.map +1 -0
- package/dist/ui/forms/index.d.ts +11 -0
- package/dist/ui/forms/index.d.ts.map +1 -0
- package/dist/ui/forms/index.js +7 -0
- package/dist/ui/forms/index.js.map +1 -0
- package/dist/ui/forms/journey.d.ts +19 -0
- package/dist/ui/forms/journey.d.ts.map +1 -0
- package/dist/ui/forms/journey.js +54 -0
- package/dist/ui/forms/journey.js.map +1 -0
- package/dist/ui/forms/model.d.ts +239 -0
- package/dist/ui/forms/model.d.ts.map +1 -0
- package/dist/ui/forms/model.js +556 -0
- package/dist/ui/forms/model.js.map +1 -0
- package/dist/ui/forms/sample.d.ts +36 -0
- package/dist/ui/forms/sample.d.ts.map +1 -0
- package/dist/ui/forms/sample.js +444 -0
- package/dist/ui/forms/sample.js.map +1 -0
- package/dist/ui/forms/types.d.ts +454 -0
- package/dist/ui/forms/types.d.ts.map +1 -0
- package/dist/ui/forms/types.js +2 -0
- package/dist/ui/forms/types.js.map +1 -0
- package/dist/ui/index.d.ts +2 -0
- package/dist/ui/index.d.ts.map +1 -1
- package/dist/ui/index.js +4 -0
- package/dist/ui/index.js.map +1 -1
- package/dist/ui/integrations/Overlay.d.ts +3 -1
- package/dist/ui/integrations/Overlay.d.ts.map +1 -1
- package/dist/ui/integrations/Overlay.js +2 -2
- package/dist/ui/integrations/Overlay.js.map +1 -1
- package/dist/ui/portfolio/PortfolioView.d.ts +4 -1
- package/dist/ui/portfolio/PortfolioView.d.ts.map +1 -1
- package/dist/ui/portfolio/PortfolioView.js.map +1 -1
- package/dist/ui/sample.d.ts +3 -1
- package/dist/ui/sample.d.ts.map +1 -1
- package/dist/ui/sample.js +2 -0
- package/dist/ui/sample.js.map +1 -1
- package/dist/ui/seed.d.ts +1 -1
- package/dist/ui/ui.css +2301 -0
- package/docs/federation.md +19 -0
- package/docs/meta-ads.md +713 -7
- package/docs/upgrading.md +70 -0
- package/llms.txt +1 -0
- package/package.json +1 -1
- package/src/component/_generated/api.ts +10 -0
- package/src/component/_generated/component.ts +289 -20
- package/src/component/_generated/server.ts +2 -0
- package/src/component/constants.ts +33 -0
- package/src/component/convex.config.ts +7 -0
- package/src/component/errors.ts +3 -1
- package/src/component/http.ts +75 -0
- package/src/component/leadAdsValidators.ts +365 -0
- package/src/component/leadFormRules.ts +202 -0
- package/src/component/leadFormStore.ts +129 -0
- package/src/component/leadgenToken.ts +348 -0
- package/src/component/leads.ts +136 -84
- package/src/component/meta.ts +21 -1
- package/src/component/metaFake.ts +485 -1
- package/src/component/metaGraph.ts +327 -0
- package/src/component/metaLeads.ts +3101 -0
- package/src/component/schema.ts +187 -1
- package/src/component/validators.ts +17 -1
- 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
|
+
}
|
package/src/component/leads.ts
CHANGED
|
@@ -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
|
-
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
|
|
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
|
-
|
|
300
|
+
existing,
|
|
292
301
|
args.column,
|
|
293
|
-
|
|
302
|
+
eventTime(args.occurredAt, now),
|
|
294
303
|
meta,
|
|
295
304
|
);
|
|
296
|
-
if (
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
|
|
300
|
-
|
|
301
|
-
|
|
302
|
-
|
|
303
|
-
|
|
304
|
-
|
|
305
|
-
|
|
306
|
-
|
|
307
|
-
|
|
308
|
-
|
|
309
|
-
})
|
|
310
|
-
|
|
311
|
-
|
|
312
|
-
|
|
313
|
-
|
|
314
|
-
|
|
315
|
-
|
|
316
|
-
|
|
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
|
|
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
|
|
1084
|
-
* ads
|
|
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 (
|
|
1186
|
+
if (!keep(row.key)) continue;
|
|
1136
1187
|
const counters = days.get(row.dayStart);
|
|
1137
1188
|
if (counters) add(counters, row);
|
|
1138
1189
|
}
|
|
1139
|
-
|
|
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,
|