@iann29/rastro 0.10.9 → 0.11.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 (180) 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 +465 -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 +274 -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 +1106 -0
  34. package/dist/component/leadAdsValidators.d.ts.map +1 -0
  35. package/dist/component/leadAdsValidators.js +311 -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 +683 -0
  65. package/dist/component/metaLeads.d.ts.map +1 -0
  66. package/dist/component/metaLeads.js +2639 -0
  67. package/dist/component/metaLeads.js.map +1 -0
  68. package/dist/component/schema.d.ts +420 -5
  69. package/dist/component/schema.d.ts.map +1 -1
  70. package/dist/component/schema.js +176 -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 +5 -5
  77. package/dist/tracker/generated.d.ts.map +1 -1
  78. package/dist/tracker/generated.js +5 -5
  79. package/dist/tracker/generated.js.map +1 -1
  80. package/dist/ui/campaigns/CampaignsView.d.ts +5 -1
  81. package/dist/ui/campaigns/CampaignsView.d.ts.map +1 -1
  82. package/dist/ui/campaigns/CampaignsView.js.map +1 -1
  83. package/dist/ui/campaigns/Drawer.d.ts.map +1 -1
  84. package/dist/ui/campaigns/Drawer.js +8 -26
  85. package/dist/ui/campaigns/Drawer.js.map +1 -1
  86. package/dist/ui/campaigns/labels.d.ts +29 -1
  87. package/dist/ui/campaigns/labels.d.ts.map +1 -1
  88. package/dist/ui/campaigns/labels.js +77 -2
  89. package/dist/ui/campaigns/labels.js.map +1 -1
  90. package/dist/ui/campaigns/model.js +2 -2
  91. package/dist/ui/campaigns/model.js.map +1 -1
  92. package/dist/ui/forms/FirstContact.d.ts +19 -0
  93. package/dist/ui/forms/FirstContact.d.ts.map +1 -0
  94. package/dist/ui/forms/FirstContact.js +81 -0
  95. package/dist/ui/forms/FirstContact.js.map +1 -0
  96. package/dist/ui/forms/FormLead.d.ts +28 -0
  97. package/dist/ui/forms/FormLead.d.ts.map +1 -0
  98. package/dist/ui/forms/FormLead.js +199 -0
  99. package/dist/ui/forms/FormLead.js.map +1 -0
  100. package/dist/ui/forms/FormRulesDrawer.d.ts +49 -0
  101. package/dist/ui/forms/FormRulesDrawer.d.ts.map +1 -0
  102. package/dist/ui/forms/FormRulesDrawer.js +149 -0
  103. package/dist/ui/forms/FormRulesDrawer.js.map +1 -0
  104. package/dist/ui/forms/FormsView.d.ts +10 -0
  105. package/dist/ui/forms/FormsView.d.ts.map +1 -0
  106. package/dist/ui/forms/FormsView.js +227 -0
  107. package/dist/ui/forms/FormsView.js.map +1 -0
  108. package/dist/ui/forms/Icons.d.ts +46 -0
  109. package/dist/ui/forms/Icons.d.ts.map +1 -0
  110. package/dist/ui/forms/Icons.js +45 -0
  111. package/dist/ui/forms/Icons.js.map +1 -0
  112. package/dist/ui/forms/RuleRow.d.ts +37 -0
  113. package/dist/ui/forms/RuleRow.d.ts.map +1 -0
  114. package/dist/ui/forms/RuleRow.js +80 -0
  115. package/dist/ui/forms/RuleRow.js.map +1 -0
  116. package/dist/ui/forms/WhatsAppButtonGuide.d.ts +33 -0
  117. package/dist/ui/forms/WhatsAppButtonGuide.d.ts.map +1 -0
  118. package/dist/ui/forms/WhatsAppButtonGuide.js +90 -0
  119. package/dist/ui/forms/WhatsAppButtonGuide.js.map +1 -0
  120. package/dist/ui/forms/index.d.ts +11 -0
  121. package/dist/ui/forms/index.d.ts.map +1 -0
  122. package/dist/ui/forms/index.js +7 -0
  123. package/dist/ui/forms/index.js.map +1 -0
  124. package/dist/ui/forms/journey.d.ts +19 -0
  125. package/dist/ui/forms/journey.d.ts.map +1 -0
  126. package/dist/ui/forms/journey.js +54 -0
  127. package/dist/ui/forms/journey.js.map +1 -0
  128. package/dist/ui/forms/model.d.ts +243 -0
  129. package/dist/ui/forms/model.d.ts.map +1 -0
  130. package/dist/ui/forms/model.js +564 -0
  131. package/dist/ui/forms/model.js.map +1 -0
  132. package/dist/ui/forms/sample.d.ts +36 -0
  133. package/dist/ui/forms/sample.d.ts.map +1 -0
  134. package/dist/ui/forms/sample.js +448 -0
  135. package/dist/ui/forms/sample.js.map +1 -0
  136. package/dist/ui/forms/types.d.ts +472 -0
  137. package/dist/ui/forms/types.d.ts.map +1 -0
  138. package/dist/ui/forms/types.js +2 -0
  139. package/dist/ui/forms/types.js.map +1 -0
  140. package/dist/ui/index.d.ts +2 -0
  141. package/dist/ui/index.d.ts.map +1 -1
  142. package/dist/ui/index.js +4 -0
  143. package/dist/ui/index.js.map +1 -1
  144. package/dist/ui/integrations/Overlay.d.ts +3 -1
  145. package/dist/ui/integrations/Overlay.d.ts.map +1 -1
  146. package/dist/ui/integrations/Overlay.js +2 -2
  147. package/dist/ui/integrations/Overlay.js.map +1 -1
  148. package/dist/ui/portfolio/PortfolioView.d.ts +4 -1
  149. package/dist/ui/portfolio/PortfolioView.d.ts.map +1 -1
  150. package/dist/ui/portfolio/PortfolioView.js.map +1 -1
  151. package/dist/ui/sample.d.ts +3 -1
  152. package/dist/ui/sample.d.ts.map +1 -1
  153. package/dist/ui/sample.js +2 -0
  154. package/dist/ui/sample.js.map +1 -1
  155. package/dist/ui/seed.d.ts +1 -1
  156. package/dist/ui/ui.css +2305 -0
  157. package/docs/federation.md +19 -0
  158. package/docs/meta-ads.md +723 -7
  159. package/docs/upgrading.md +70 -0
  160. package/llms.txt +1 -0
  161. package/package.json +1 -1
  162. package/src/component/_generated/api.ts +10 -0
  163. package/src/component/_generated/component.ts +290 -20
  164. package/src/component/_generated/server.ts +2 -0
  165. package/src/component/constants.ts +33 -0
  166. package/src/component/convex.config.ts +7 -0
  167. package/src/component/errors.ts +3 -1
  168. package/src/component/http.ts +75 -0
  169. package/src/component/leadAdsValidators.ts +376 -0
  170. package/src/component/leadFormRules.ts +202 -0
  171. package/src/component/leadFormStore.ts +129 -0
  172. package/src/component/leadgenToken.ts +348 -0
  173. package/src/component/leads.ts +136 -84
  174. package/src/component/meta.ts +21 -1
  175. package/src/component/metaFake.ts +485 -1
  176. package/src/component/metaGraph.ts +327 -0
  177. package/src/component/metaLeads.ts +3111 -0
  178. package/src/component/schema.ts +197 -1
  179. package/src/component/validators.ts +17 -1
  180. package/src/tracker/generated.ts +5 -5
package/docs/meta-ads.md CHANGED
@@ -30,6 +30,12 @@ The Meta connection and the Conversions API sender ship in 0.10.0:
30
30
  | Spend and catalog sync | One scheduler chain for every linked ad account |
31
31
  | `RASTRO_SECRETS_KEY`, `RASTRO_META_FAKE`, `RASTRO_META_APP_ID/SECRET` | Optional env bindings |
32
32
  | Conversions sent to the pixel | Sales and new leads from the lead methods; mapped columns |
33
+ | Lead Ads (instant forms) into the host's CRM, `metaLeads` capability | Unreleased; see [Lead Ads](#lead-ads-instant-forms) |
34
+
35
+ Lead Ads (contract §12) is not released yet. Both halves are in this repository:
36
+ the component's Pages, forms, rules and leadgen route, and the control plane's
37
+ webhook and forwarding, which deliver only to hosts that serve the `metaLeads`
38
+ side (§12.4, see [Host side](#host-side)).
33
39
 
34
40
  ## Turn on the `metaAds` capability
35
41
 
@@ -140,18 +146,26 @@ charts.
140
146
 
141
147
  - Arguments: one to ten unique `siteIds`, an inclusive `from`/`to` covering at
142
148
  most 92 UTC days, and at most one filter: `adId` for one ad's leads, or
143
- `origin` for one origin's (`"ctwa_ad"` for the leads the ads bought). Passing
144
- both fails with `INVALID_ARGUMENT`. Without a filter the series counts every
145
- lead.
149
+ `origin` for one origin's (`"ctwa_ad"` for the click-to-WhatsApp ads' leads),
150
+ or `origin: "paid"` for every lead an ad bought (`ctwa_ad` and `lead_form`
151
+ together, the origins `bySite[].paid` counts). Passing both fails with
152
+ `INVALID_ARGUMENT`. Without a filter the series counts every lead. A host on a
153
+ release before Lead Ads counts nothing under `"paid"`; it has no `lead_form`
154
+ leads either, so a dashboard asks such a host for `"ctwa_ad"`. Every host from
155
+ the Lead Ads release advertises the capability `leadsPaid` with `leads` (not
156
+ an opt-in: it only says `leadsDaily` understands `"paid"`), so a dashboard
157
+ asks for `"paid"` whenever the manifest carries it, not only on hosts that
158
+ advertise `metaLeads`.
146
159
  - Answer: every UTC day the range touches, oldest first, empty days included
147
160
  with zeros: `{ dayStart, leads, qualified, won, revenueCents, spendCents }`.
148
161
  - Counting is by cohort, like `leads.report`: a qualification or sale lands on
149
162
  the day its lead first arrived.
150
163
  - `spendCents` is that day's spend for the same scope: the site's ads, one ad,
151
- or, with `origin: "ctwa_ad"`, the ads again. It is `null` for an organic
152
- origin and for sites no linked ad account covers. Spend keeps the ad account's
153
- local date while cohorts use the UTC day, so a daily chart may show a day of
154
- skew; totals over a week or more agree.
164
+ or, with `origin: "ctwa_ad"` or `"paid"`, the ads again. It is `null` for an
165
+ organic origin, for `lead_form` alone (the spend is not split between the two
166
+ kinds of ad) and for sites no linked ad account covers. Spend keeps the ad
167
+ account's local date while cohorts use the UTC day, so a daily chart may show
168
+ a day of skew; totals over a week or more agree.
155
169
 
156
170
  ### `meta.status` (`metaStatus`)
157
171
 
@@ -793,3 +807,705 @@ outros clientes" to the events sheet. Saving with it checked calls
793
807
  `onSaveEventMap` for the client, then `onCopyEventMap` with the other clients
794
808
  whose board declares the same columns and whose pixel is not another owner's
795
809
  (`eventMapCopyTargets` computes them).
810
+
811
+ ## Lead Ads (instant forms)
812
+
813
+ A Meta instant form's leads go straight into the host's CRM. Rastro talks to
814
+ Meta; the host owns the person. Rastro lists the owner's Facebook Pages and
815
+ their forms, receives Meta's lead notifications, reads each lead with the Page's
816
+ own token, applies the form's rules and hands the result to the host's **form
817
+ lead handler** in the same transaction. The host creates or updates the contact
818
+ and its card, and owns the first contact.
819
+
820
+ **A lead's personal data is never stored by Rastro.** Name, phone, e-mail and
821
+ every answer typed freely go from Meta to the handler in memory and are not
822
+ written to any table, scheduled job or log. Rastro keeps the origin (Page, form,
823
+ ad, ad set, campaign), the times, the answers to **choice questions** (the ones
824
+ rules read) and what the rules decided.
825
+
826
+ ### Turn it on
827
+
828
+ 1. Pass the federation audience (and the issuer, when it is not the production
829
+ dashboard) to the component, the same values the host's `auth.config.ts`
830
+ trusts (the leadgen route checks the control plane's token against them):
831
+
832
+ ```ts
833
+ app.use(rastro, {
834
+ env: {
835
+ // ...the Meta bindings above
836
+ RASTRO_FEDERATION_ISSUER: app.env.RASTRO_FEDERATION_ISSUER,
837
+ RASTRO_FEDERATION_AUDIENCE: app.env.RASTRO_FEDERATION_AUDIENCE,
838
+ },
839
+ });
840
+ ```
841
+
842
+ The component's env is its own: set both on the deployment
843
+ (`RASTRO_FEDERATION_AUDIENCE` is required) and pass them here. The issuer
844
+ defaults to `https://site.api.amagerastro.com/federation`, as in the host's
845
+ federation API. The audience has no default: it is the host's **functions
846
+ URL** (`https://<deployment>.convex.cloud`, or its custom functions domain),
847
+ the same `applicationID` the host's `auth.config.ts` names for the issuer
848
+ ([federation-setup.md](federation-setup.md)), not its site URL. Without it
849
+ the route answers `503 FEDERATION_NOT_CONFIGURED` to every forward, and
850
+ `linkPage` refuses with `FEDERATION_NOT_CONFIGURED`, so no Page is linked
851
+ (and routed) to a host that cannot take its leads. A host paired with another
852
+ dashboard (a DEV control plane) sets the issuer to that dashboard's:
853
+ `metaLinkedPages` reports it, and the dashboard routes no lead to a host that
854
+ trusts another issuer. Mount the component with `httpPrefix: "/rastro/"`: the
855
+ dashboard posts each forward to `<site URL>/rastro/meta/leadgen`, and a host
856
+ under another prefix gets its leads only from the reconciliation.
857
+
858
+ 2. Write the handler, an internal **mutation** validated by
859
+ `formLeadPayloadValidator` and idempotent on `leadgenId` (Meta's webhook and
860
+ the reconciliation may both bring a lead), and register it once:
861
+
862
+ ```ts
863
+ import { createFunctionHandle } from "convex/server";
864
+ import { formLeadPayloadValidator } from "@iann29/rastro";
865
+
866
+ export const formLeadArrived = internalMutation({
867
+ args: formLeadPayloadValidator,
868
+ returns: v.null(),
869
+ handler: async (ctx, lead) => {
870
+ // Find the contact by lead.contact.phone, create or update it and its
871
+ // card in lead.decision's column (or the discard column with
872
+ // lead.decision.discard), attach lead.answers and lead.origin. Later
873
+ // stage changes use lead.leadKey with setLeadStage.
874
+ return null;
875
+ },
876
+ });
877
+
878
+ export const registerFormLeadHandler = internalMutation({
879
+ args: {},
880
+ handler: async (ctx) => {
881
+ await rastro.setFormLeadHandler(ctx, {
882
+ handle: await createFunctionHandle(
883
+ internal.rastroLeads.formLeadArrived,
884
+ ),
885
+ });
886
+ },
887
+ });
888
+ ```
889
+
890
+ 3. Opt in on the federated surface with `metaLeads: true` (beside
891
+ `metaAds: true`, which it needs: the Meta login and the Integrações screen
892
+ come with `metaAds`, so `metaLeads` alone throws at setup) and re-export the
893
+ Lead Ads functions from `convex/rastroFederation.ts` (see
894
+ [Federated Lead Ads functions](#federated-lead-ads-functions)).
895
+ 4. Ask the dashboard operator to add the connection's deployment origin
896
+ (`https://<name>.convex.cloud`, the functions URL) to the control plane's
897
+ `RASTRO_META_LEADS_DEPLOYMENT_ORIGINS`, and a custom-domain site URL to
898
+ `RASTRO_FEDERATION_ALLOWED_DEPLOYMENT_ORIGINS` (see
899
+ [Which hosts may receive leads](#lead-ads-webhook-control-plane)). Until then
900
+ no webhook is forwarded to the host, and leads only come in through the
901
+ 15-minute reconciliation.
902
+
903
+ Without a handler nothing is lost: a lead is not marked as ingested, the
904
+ reconciliation reads it again once a handler exists (registering one brings the
905
+ waiting Pages' runs forward), and `metaPages`/`metaForms` read
906
+ `handlerReady: false` with `lastError: "HANDLER_MISSING"`.
907
+
908
+ ### The handler's payload
909
+
910
+ ```ts
911
+ {
912
+ siteId: string;
913
+ leadId: string; // Rastro's lead
914
+ leadKey: string; // `meta_lead:<leadgenId>`: what setLeadStage takes
915
+ leadgenId: string;
916
+ receivedAt: number; // when the lead sent the form
917
+ contact: {
918
+ name: string | null;
919
+ phone: string | null;
920
+ email: string | null;
921
+ };
922
+ freeAnswers: Array<{ questionKey: string; label: string; value: string }>;
923
+ answers: Array<{ questionKey: string; label: string; values: string[] }>;
924
+ decision: { column: string | null } | { discard: string };
925
+ rulePath: number[]; // the rules looked at, in order
926
+ ruleIndex: number | null; // the rule that decided
927
+ origin: {
928
+ pageId: string;
929
+ pageName: string | null;
930
+ formId: string;
931
+ formName: string | null;
932
+ adId: string | null; // with adName, adsetId, adsetName, campaignId,
933
+ // campaignName (null until the catalog sync names them)
934
+ platform: string | null; // "fb" or "ig"
935
+ isOrganic: boolean;
936
+ };
937
+ firstContact: {
938
+ mode: "lead_starts" | "one_click" | "automatic";
939
+ text?: string;
940
+ brakes?: {
941
+ waitMinutes: number;
942
+ maxPerHour: number;
943
+ businessHoursOnly: boolean;
944
+ };
945
+ };
946
+ }
947
+ ```
948
+
949
+ The component records the lead before it calls the handler: channel `form`,
950
+ entry point `lead_form`, the ad as its origin, the decided column (when the
951
+ rules named one) and the form. Do not `recordLead` it again under another key;
952
+ move it with `setLeadStage` and `leadKey`, so the sale reaches the reports and
953
+ the pixel. A handler that throws rolls the whole ingestion back (the lead, its
954
+ record, the rules' counts), and the next reconciliation brings it again.
955
+
956
+ ### Pages
957
+
958
+ On a host that registered its form lead handler (`setFormLeadHandler`, the
959
+ opt-in), every connect (`setMetaConnection`, `connectMetaWithCode`) and every
960
+ `listMetaAdAccounts` also reads the owner's Pages
961
+ (`GET /me/accounts?fields=id,name,access_token,tasks`) and seals each Page's own
962
+ token with `RASTRO_SECRETS_KEY`; the first registration reads the Pages of the
963
+ owners already connected. A host without the handler makes no Pages read and
964
+ keeps no Page token. A login without the Page permissions lists no Page and
965
+ still connects. `leadsAllowed` says Meta granted `leads_retrieval` for the Page
966
+ (from `debug_token`'s granular scopes when the app env is set, else from the
967
+ token's permissions) and the login has a task that reaches its leads
968
+ (`ADVERTISE` or `MANAGE`).
969
+
970
+ - `metaPages({ ownerKey })` (query): `pageId`, `name`, `tasks`, `leadsAllowed`,
971
+ `linkedSiteId` (whoever linked it: one Page feeds one site), `own`,
972
+ `subscribedAt`, `lastError` and `handlerReady` (the host registered its form
973
+ lead handler). Never a token.
974
+ - `lastError` is a code, never a message (the message stays in the component),
975
+ or `null`. Each says who can fix it:
976
+
977
+ | Code | Cause | Fixed by |
978
+ | ------------------------- | -------------------------------------------------------- | ------------------------------- |
979
+ | `META_TOKEN_INVALID` | Meta refused the Page's (or the connection's) token | reconnecting Meta |
980
+ | `META_PERMISSION_MISSING` | no `leads_retrieval` for the Page, or the login lost it | reconnecting Meta |
981
+ | `HANDLER_MISSING` | the host removed its form lead handler | the host (`setFormLeadHandler`) |
982
+ | `HANDLER_FAILED` | the host's handler threw; the lead is read again | the host |
983
+ | `HANDLER_GAVE_UP` | the handler threw 5 times; the lead was skipped for good | the host |
984
+ | `SECRETS_KEY_MISSING` | the component cannot open the Page token | the host's env |
985
+ | `META_TRUNCATED` | a busy backfill goes on next run | itself |
986
+ | `META_RATE_LIMITED` | Meta's rate limit: the next run waits an hour | itself |
987
+ | `META_UNAVAILABLE` | Meta failed otherwise; retried with backoff | itself |
988
+ | `RUN_TIMEOUT` | the previous reconciliation did not finish | itself |
989
+
990
+ The package exports the list as `META_PAGE_ERROR_CODES`. A Page another owner
991
+ linked reads `null`: its state is that owner's.
992
+
993
+ - `linkPage({ siteId, pageId, ownerKey, backfillDays? })` (action) subscribes
994
+ the app to the Page's `leadgen` webhook
995
+ (`POST /{page}/subscribed_apps?subscribed_fields=leadgen` with the Page token;
996
+ Meta must answer `success: true`), reads its forms, records the link and
997
+ starts the reconciliation. A Page another site has is `CONFLICT`; one without
998
+ lead access is `META_PERMISSION_MISSING`; on a host without
999
+ `RASTRO_FEDERATION_AUDIENCE` every link is `FEDERATION_NOT_CONFIGURED`.
1000
+ `backfillDays` (0–30, default 0) also brings the leads of the days before. At
1001
+ most 20 Pages a site.
1002
+ - `unlinkPage({ siteId, pageId, ownerKey? })` (action) forgets the link and its
1003
+ reconciliation. It leaves the app's `leadgen` subscription on the Page: the
1004
+ subscription belongs to the Page and the one app every host shares, so taking
1005
+ it off would silence another host that links the Page now (the control plane
1006
+ drops notifications for a Page no host claims). Every 6 hours the
1007
+ reconciliation of a linked Page subscribes it again (idempotent), so a host
1008
+ keeps its notifications even after an older host's unlink took them off. The
1009
+ site's form rules and the leads already in stay.
1010
+ - A Page the login no longer lists, or one without lead access, stops with
1011
+ `META_PERMISSION_MISSING`; one whose token Meta refuses with
1012
+ `META_TOKEN_INVALID`; a connect or refresh that lists it again resumes it. A
1013
+ connection whose token Meta refuses stops its Pages too. Removing the
1014
+ connection removes its Pages and links.
1015
+ - `metaLinkedPages({ siteIds })` (query):
1016
+ `{ pages: Array<{ pageId, siteId }>, leadgenReady, issuer, audience, handlerReady }`,
1017
+ what the dashboard's control plane reads to route Meta's webhooks to this
1018
+ host. `handlerReady` says the host registered its form lead handler: the
1019
+ handler is the Lead Ads opt-in, and without it no Page is read, so Formulários
1020
+ with no Pages says the host is not ready instead of asking to reconnect Meta.
1021
+ `leadgenReady` says the component has `RASTRO_FEDERATION_AUDIENCE` (its
1022
+ leadgen route can verify a forward); `issuer` is the issuer that route checks
1023
+ forwards against (`RASTRO_FEDERATION_ISSUER`, trailing slash dropped, or the
1024
+ production default); `audience` is the `RASTRO_FEDERATION_AUDIENCE` it expects
1025
+ (trailing slash dropped), or `null`. The control plane routes the Pages only
1026
+ when `leadgenReady` is true, `issuer` is its own and `audience` is the
1027
+ connection's deployment URL.
1028
+
1029
+ ### Forms and their rules
1030
+
1031
+ - `metaForms({ siteId, now?, ownerKey? })` (query): the Pages linked to the
1032
+ site, each with `leadsAllowed`, `subscribedAt`, `lastReconcileAt`,
1033
+ `lastLeadAt`, `lastError` (the same codes as `metaPages`), `handlerReady` and
1034
+ its forms: `formId`, `name`, `status` (`ACTIVE`, `ARCHIVED`…), `questions`
1035
+ (choice questions carry their `options`), `whatsappButton` (`true`/`false`
1036
+ from the end screen's button type, `null` when Meta does not say),
1037
+ `whatsappButtonConfirmed` (the host ticked "Já liguei"), `createdTime`,
1038
+ `leads30d` and `daily` (the 30 UTC days up to `now`, null without it),
1039
+ `lastLeadAt`, `lastLeadVia` (how that newest lead came in: `"webhook"` when
1040
+ the dashboard forwarded Meta's notification, `"reconcile"` when the
1041
+ reconciliation brought it, `null` for a lead from before 0.11.1) and a `rules`
1042
+ summary. At most 500 forms a site in all, every Page's `ACTIVE` forms first,
1043
+ then the others while room is left (Meta keeps archived forms forever). New
1044
+ forms show up by themselves on every reconciliation run;
1045
+ `refreshForms({ siteId, pageId? })` (action) reads them at once.
1046
+ - `setFormRules({ siteId, formId, entryColumn, rules, firstContact, whatsappButtonConfirmed?, ownerKey?, onlyOwn? })`
1047
+ (mutation) replaces the form's rules. Each rule is
1048
+ `{ questionKey, answers, to }`: `questionKey` is a choice question of the
1049
+ form, `answers` name its options (by key or shown value; stored by key) and
1050
+ `to` is `{ column }` or `{ discard: reason }`. The first matching rule
1051
+ decides; with none, the lead goes to `entryColumn` (`null`: the host's own
1052
+ entry). Columns must be on the site's pipeline (`setPipeline`).
1053
+ `firstContact.mode` is `lead_starts`, `one_click` (needs `text`;
1054
+ `{primeiro_nome}` is the host's to fill) or `automatic` (needs `brakes`: wait
1055
+ 15–1440 minutes, 1–120 an hour, business hours or not). Editing decides the
1056
+ next leads only. At most 20 rules.
1057
+ - `formRules({ siteId, formId, now?, ownerKey? })` (query): the rules, each with
1058
+ `monthCount` (the leads it decided in the 30 days up to `now`; a rule's count
1059
+ follows its question, answers and target, so reordering keeps it and editing
1060
+ starts a new one), `entryColumn`, `firstContact`, `configured` and, with an
1061
+ owner, `own`.
1062
+ - `simulateFormRules({ siteId, formId, entryColumn, rules, now })` (query): the
1063
+ last 30 days' leads under a draft: per rule, per column, per discard reason,
1064
+ and those left to the entry. It reads the choice answers Rastro keeps, never
1065
+ Meta. A rule still being written (no answer yet) counts zero. `entry` counts
1066
+ the leads no rule matched; when the draft names an `entryColumn`, `columns`
1067
+ counts them under that column too, so a screen adds `entry` only when no
1068
+ `columns` row names the entry (unlike `formsReport`, whose `entry` is only the
1069
+ leads sent to the host's own entry).
1070
+ - `returnFormLead({ siteId, leadId, ownerKey? })` (mutation) sends a discarded
1071
+ lead back to the funnel: its decision becomes the host's entry and the reason
1072
+ stays as history (`returned`). The host moves its card itself.
1073
+ - `formsReport({ siteIds, from, to })` (query): per form, over the cohort days
1074
+ in the range, `leads`, `qualified`, `won`, `revenueCents` (from the lead
1075
+ rollups), the leads sent to each column, to the `entry`, `discarded` per
1076
+ reason, and `returned`. `partial` when a read cap was hit. There is one row
1077
+ per `(siteId, formId)`: a Page moved from one site to another inside the range
1078
+ has a row under each, so key rows by both and read the one of the site the
1079
+ Page feeds now. The range is whole UTC days: `from` floors to its day and `to`
1080
+ takes in the whole UTC day it falls on, so a 30-day window is
1081
+ `to = end of a UTC day`, `from = to + 1 − 30 days`.
1082
+
1083
+ ### How a lead comes in
1084
+
1085
+ 1. Meta posts the `leadgen` webhook to the Rastro dashboard's control plane,
1086
+ which knows which host links the Page (`metaLinkedPages`) and forwards
1087
+ `{ pageId, leadgenId }` to `POST <host site>/rastro/meta/leadgen`.
1088
+ 2. The component's route checks the control plane's token: RS256 under the
1089
+ issuer's published keys (`<issuer>/.well-known/openid-configuration`), issuer
1090
+ and audience as the host trusts them, subject `rastro:meta-webhook`, at most
1091
+ 10 minutes of lifetime (the control plane signs 2), purpose `meta_leadgen`,
1092
+ naming its connection, organization and connection version, and bound to
1093
+ exactly that Page and leadgen id. The names come from `META_LEADGEN_TOKEN`
1094
+ (exported by the package; the control plane signs with the same object,
1095
+ contract §12.3). Then it schedules the ingestion and answers `202`
1096
+ `{ accepted: true }`; a Page this host does not link answers `200`
1097
+ `{ accepted: false, reason: "PAGE_NOT_LINKED" }`, a bad token `401`
1098
+ `{ error: { code: "UNAUTHENTICATED" } }`, a host without the audience env
1099
+ `503` `{ error: { code: "FEDERATION_NOT_CONFIGURED" } }`, and an issuer whose
1100
+ keys cannot be read right now (unreachable, `5xx`, `429`, not JSON), or whose
1101
+ keys were read less than a minute ago and lack the token's key id (a key
1102
+ rotated meanwhile), `503` `{ error: { code: "ISSUER_UNREACHABLE" } }`, which
1103
+ is retried. The control plane reads these bodies (see
1104
+ [Forwarding](#lead-ads-webhook-control-plane)).
1105
+ 3. The ingestion reads the lead from Meta with the Page token
1106
+ (`GET /{leadgen}?fields=created_time,field_data,ad_id,adset_id,campaign_id,form_id,platform,is_organic`),
1107
+ splits its answers (identity and free ones in memory only), applies the
1108
+ rules, records the lead and calls the handler. Idempotent on the leadgen id.
1109
+ 4. A scheduler chain (no cron) reads every linked Page every 15 minutes: the
1110
+ Page's forms, then each active form's leads since its cursor
1111
+ (`GET /{form}/leads` filtered on `time_created`), oldest first, through the
1112
+ same ingestion. Meta lists a form's leads newest first and one listing stops
1113
+ at 2,000, so a capped listing is never trusted: the run halves the window
1114
+ (`time_created` between the cursor and an end) until Meta lists it whole,
1115
+ ingests it and moves the cursor to its end, then reads the next window (12
1116
+ listings a form a run at most; a busy backfill goes on next run, with
1117
+ `META_TRUNCATED` shown meanwhile). Rate limits push the next run back an
1118
+ hour; other failures retry after 15 minutes, doubling up to 6 hours. It never
1119
+ uses `paginate()`.
1120
+ 5. A lead that cannot come in holds its form's cursor there (the handler
1121
+ missing, or throwing); the leads after it are still ingested and skipped by
1122
+ their leadgen id when read again. Each failed commit is counted by leadgen id
1123
+ in `formLeadFailures` (ids, attempts and the error's code, never its
1124
+ message). A lead that failed 5 times while later leads of its form came in is
1125
+ given up on: the cursor moves past it, the link shows
1126
+ `HANDLER_GAVE_UP: … skipped after 5 failed attempts (first: <leadgenId>)` for
1127
+ that run and the row keeps `skippedAt`. While every lead fails (the host's
1128
+ CRM is down) none is given up on. The host's error message is never logged
1129
+ (it may carry the contact): only a `ConvexError`'s `data.code` or the error's
1130
+ class name.
1131
+ 6. A lead the host removes (`leads.remove`) leaves its leadgen id in
1132
+ `formLeadTombstones` (nothing else), so no later read (the webhook retried,
1133
+ the reconciliation, a new link's backfill) brings it back.
1134
+
1135
+ ### Reports
1136
+
1137
+ A form lead's origin is `lead_form` when an ad brought it and
1138
+ `lead_form_organic` when the form was filled without one. `lead_form` counts as
1139
+ paid: it is in `bySite[].paid` beside `ctwa_ad`, and its ad's row in the
1140
+ campaign tree counts it. Spend stays on the `ctwa_ad` origin row, as before;
1141
+ cost per lead and return come from `bySite[].paid`. `leads.daily` takes
1142
+ `origin: "lead_form"` (no spend) and `origin: "paid"` (`ctwa_ad` and `lead_form`
1143
+ together, with the spend that bought them): a sales × spend line asks for
1144
+ `"paid"`. A lead's journey (`leads.journey`) carries `form`: its choice answers
1145
+ with their labels, the origin, the decision, `rulePath` and `returned`.
1146
+
1147
+ ### Federated Lead Ads functions
1148
+
1149
+ With `metaLeads: true` the federated surface serves the forms screen to the
1150
+ Rastro dashboard, acting for the dashboard connection's own Meta owner like the
1151
+ rest of the Meta surface.
1152
+
1153
+ | Function | Kind | Needs |
1154
+ | ----------------------- | -------- | --------- |
1155
+ | `metaPages` | query | reader |
1156
+ | `metaForms` | query | reader |
1157
+ | `metaFormRules` | query | reader |
1158
+ | `simulateMetaFormRules` | query | reader |
1159
+ | `metaFormsReport` | query | reader |
1160
+ | `metaLinkedPages` | query | reader |
1161
+ | `linkMetaPage` | action | configure |
1162
+ | `unlinkMetaPage` | action | configure |
1163
+ | `refreshMetaForms` | action | configure |
1164
+ | `setMetaFormRules` | mutation | configure |
1165
+ | `returnMetaFormLead` | mutation | configure |
1166
+
1167
+ A Page linked to the site by another owner of the host, and the rules of its
1168
+ forms, are locked to the dashboard (`CONFLICT`: "Ligado pelo uze.ai"); a Page
1169
+ linked to a site outside the grant reads `linkedOutsideScope: true` without its
1170
+ site. The three actions authorize through `authorizeMetaAction`, which the host
1171
+ already exports. Without `metaLeads` the reads refuse with
1172
+ `FEDERATION_INVALID_SCOPE` ("This host does not bring Meta lead forms").
1173
+ `metaLeads` needs `metaAds`: `exposeFederatedAnalyticsApi` throws at setup when
1174
+ it gets `metaLeads: true` alone, since the dashboard shows the Formulários
1175
+ section inside Integrações and links Pages with the Meta login, both of
1176
+ `metaAds`.
1177
+
1178
+ ```ts
1179
+ export const {
1180
+ // ...
1181
+ metaPages,
1182
+ metaForms,
1183
+ metaFormRules,
1184
+ simulateMetaFormRules,
1185
+ metaFormsReport,
1186
+ metaLinkedPages,
1187
+ linkMetaPage,
1188
+ unlinkMetaPage,
1189
+ refreshMetaForms,
1190
+ setMetaFormRules,
1191
+ returnMetaFormLead,
1192
+ } = federated;
1193
+ ```
1194
+
1195
+ ### Lead Ads in the demo
1196
+
1197
+ With `RASTRO_META_FAKE=true` every login lists a Facebook Page per sample client
1198
+ (Loja X, Clínica Sorriso, Pet Mania, Casa Aurora) plus "Sorriso Kids", whose
1199
+ leads Meta did not grant. Clínica Sorriso has the four forms of the approved
1200
+ screens ("Avaliação gratuita", "Clareamento — orçamento", "Lente de contato
1201
+ dental", archived "Promo setembro"); the others one each. Every active form
1202
+ brings made-up leads at a steady pace (Avaliação gratuita one every 11h15), so a
1203
+ linked Page keeps receiving them as time passes. The example host registers its
1204
+ handler in the demo's Meta step; `demoAgency:seedAgencyForms` links each
1205
+ client's Page with 30 days of leads (never part of `seedDemo`, whose numbers are
1206
+ the screens' sample).
1207
+
1208
+ ## Lead Ads webhook (control plane)
1209
+
1210
+ Meta sends the lead notifications of every Page to one callback per app. For the
1211
+ "Amage Rastro" app that callback is the Rastro control plane, which only routes:
1212
+ it checks Meta's signature, finds which connected host linked the Page, and
1213
+ hands that host the Page id and the lead id. It never sees the lead itself; the
1214
+ host reads it from Meta with its own Page token. What a host must serve for it
1215
+ is under [Host side](#host-side) below.
1216
+
1217
+ **The callback.** `GET` and `POST /meta/webhook` on the control plane's
1218
+ HTTP-actions origin, in production
1219
+ `https://site.api.amagerastro.com/meta/webhook`. Two control-plane env vars:
1220
+
1221
+ - `RASTRO_META_APP_SECRET`: the app secret. Every `POST` must carry
1222
+ `X-Hub-Signature-256: sha256=<hex>`, the HMAC-SHA256 of the raw body under
1223
+ this secret, compared in constant time; a missing or wrong signature is `401`
1224
+ and nothing in the body is read. Without the variable, `POST` answers `503`,
1225
+ so Meta keeps the notifications and retries.
1226
+ - `RASTRO_META_WEBHOOK_VERIFY_TOKEN`: a random value typed once in the app's
1227
+ Webhooks setup. `GET` echoes `hub.challenge` only for `hub.mode=subscribe` and
1228
+ this token (constant-time compare); anything else is `403`.
1229
+
1230
+ In the app dashboard: Webhooks, object **Page**, callback URL above, the verify
1231
+ token, field **`leadgen`**. Each host subscribes its Pages to the app when a
1232
+ site links them. A signed `POST` answers `200` as soon as its lead ids are
1233
+ recorded; forwarding runs scheduled, so a slow host never makes Meta retry or
1234
+ disable the subscription. A signed body that is not JSON, or changes other than
1235
+ `leadgen`, are acknowledged and ignored.
1236
+
1237
+ **Which hosts may receive leads.** A host's Page list is its own word, and Page
1238
+ ids are public, so the control plane only takes it from deployments the operator
1239
+ lists in `RASTRO_META_LEADS_DEPLOYMENT_ORIGINS` (exact origins of the
1240
+ connections' deployment URLs, comma-separated). Unset, no host receives leads
1241
+ and an unknown-Page drop logs `META_LEADS_DEPLOYMENT_ORIGINS_UNSET`. A
1242
+ connection off the list is never read, and the routes it held are ignored and
1243
+ then dropped, so it can neither take a Page nor block another host's.
1244
+
1245
+ **Which host owns a Page.** Federation only goes from the dashboard to the host,
1246
+ so the control plane reads the hosts: on a listed connection advertising
1247
+ `metaLeads`, it calls the federated query `metaLinkedPages({ siteIds })` →
1248
+ `{ pages: Array<{ pageId; siteId }>; leadgenReady; issuer; audience }` (ten
1249
+ sites per call, at most the `maxSitesPerConnection` first sites of `listSites`,
1250
+ within 45 seconds per host) with a two-minute token of its own:
1251
+ `analytics:read`, subject `rastro:meta-page-routes`. The answer becomes
1252
+ `metaPageRoutes` rows (one per Page and connection), each keeping the host's
1253
+ `leadgenReady`, `issuer` and `audience`. A Page is routed only when
1254
+ `leadgenReady` is true and `issuer` equals the control plane's own federation
1255
+ issuer; otherwise its route row records why (`HOST_NOT_READY`: the host has no
1256
+ `RASTRO_FEDERATION_AUDIENCE`; `ISSUER_MISMATCH`: the host trusts another
1257
+ dashboard; `AUDIENCE_MISMATCH`: the host's `audience` is not the connection's
1258
+ deployment URL; `HOST_SITE_URL_MISSING` / `HOST_SITE_URL_UNTRUSTED`: the
1259
+ connection has no site origin a forward may be posted to), the reason is logged
1260
+ once per refresh, and no forward is signed for it, so a lead never waits 24
1261
+ hours on a host that would refuse it. The rows are refreshed:
1262
+
1263
+ 1. when the connection is verified ("Verificar novamente"), and when a transfer
1264
+ of it settles;
1265
+ 2. right after the dashboard links or unlinks a Page (`linkMetaPage`,
1266
+ `unlinkMetaPage`): it calls the control plane's
1267
+ `metaPageRoutes.refreshConnection({ connectionId })` (a member of the
1268
+ connection's organization only), which schedules that connection's refresh;
1269
+ 3. every 30 minutes for every listed connection advertising `metaLeads`;
1270
+ 4. when a notification names a Page no route knows: every such host is read by a
1271
+ refresh that started after the notification arrived, so a Page linked seconds
1272
+ before its first lead is found. Notifications arriving while a refresh runs
1273
+ wait for it (as long as it may run: five minutes), refreshes keep 10 seconds
1274
+ between them, and a burst shares one. A notification that still matches
1275
+ nothing is dropped, logged as `META_WEBHOOK_UNKNOWN_PAGE` with the Page id
1276
+ alone; it is routed or dropped even when the refresh fails. A Page that
1277
+ refresh found no host claiming is remembered for ten minutes
1278
+ (`metaUnknownPages`): its next leads are dropped at once instead of reading
1279
+ every host again. A host listing the Page clears it.
1280
+ 5. when a Page two deployments claim has a claim read more than a minute ago:
1281
+ that connection is read again (it may be stale: the Page unlinked on its host
1282
+ without the dashboard hearing of it), and the lead is routed once more after
1283
+ that read.
1284
+
1285
+ A host that cannot be read keeps the routes it had, and so does a connection
1286
+ whose transfer is pending: its leads are recorded and retried
1287
+ (`CONNECTION_TRANSFER_PENDING`) until the transfer settles. A host re-verified
1288
+ without `metaLeads`, taken off the list, revoked or deleted loses them. Several
1289
+ organizations may connect the same host deployment; their routes for one Page
1290
+ are one host, and the oldest connection carries the lead. A Page that two
1291
+ different deployments claim routes to neither and logs `PAGE_ROUTE_CONFLICT` as
1292
+ an error (the host itself already refuses one Page on two sites); the leads stay
1293
+ with Meta, where each host's reconciliation can still read them.
1294
+
1295
+ **What the dashboard shows.**
1296
+ `metaPageRoutes.connectionRouting({ connectionId })` (a query, members of the
1297
+ connection's organization only) answers
1298
+ `{ allowed, pages: Array<{ pageId, issue }> }`: `allowed` is false when the
1299
+ control plane forwards nothing of that host (off the operator's list, without
1300
+ `metaLeads`, not active), and each routed Page's `issue` is its block reason as
1301
+ read now, `CONFLICT` when another deployment claims it too, `HOST_REFUSED` when
1302
+ the host answered the last forward to this connection with `401` although it
1303
+ reports itself ready (a key or `kid` mismatch, a `jwks_uri` that is not the
1304
+ issuer's own; kept in `metaLeadgenRefusals` until a forward is delivered again),
1305
+ or `null`. Formulários puts it on each linked Page (`routeIssue`, `NOT_ROUTED`
1306
+ for a host not forwarded to): the Page card says why its leads arrive every 15
1307
+ minutes instead of at once, and "Leads chegam na hora" is not shown.
1308
+
1309
+ **Forwarding.** Each routed notification is one `metaLeadgenForwards` row (Page
1310
+ id, lead id, connection, site, status; never lead data) and one scheduled
1311
+ attempt:
1312
+
1313
+ ```http
1314
+ POST <host site URL>/rastro/meta/leadgen
1315
+ Authorization: Bearer <token>
1316
+ Content-Type: application/json
1317
+
1318
+ { "pageId": "<page id>", "leadgenId": "<leadgen id>" }
1319
+ ```
1320
+
1321
+ The host site URL is the connection's HTTP-actions URL, and `/rastro/` is the
1322
+ component's `httpPrefix`: the control plane cannot learn a host's mount point,
1323
+ so Lead Ads forwarding requires the component mounted under `/rastro/` (a host
1324
+ under another prefix answers `404` to every forward, retried for 24 hours, and
1325
+ its leads arrive only with the reconciliation). An organization admin types that
1326
+ URL, so before each attempt it must be `https` on the default port and on
1327
+ `.convex.site` or `.synapsepanel.com`, or an origin listed in
1328
+ `RASTRO_FEDERATION_ALLOWED_DEPLOYMENT_ORIGINS` (a custom domain); otherwise the
1329
+ forward fails as `HOST_SITE_URL_UNTRUSTED` and nothing is signed. A token the
1330
+ control plane cannot sign (keys missing or not matching) is retried as
1331
+ `ISSUER_MISCONFIGURED`, not blamed on the host. The control plane reads the
1332
+ host's small JSON answer:
1333
+
1334
+ - `202 { accepted: true }` (any `2xx` that does not say otherwise) is delivered.
1335
+ - `200 { accepted: false, reason: "PAGE_NOT_LINKED" }` is `not_linked`, not
1336
+ delivered: the host no longer links the Page (unlinked, or moved to another
1337
+ host). The control plane refreshes that connection's routes at once and
1338
+ accepts the notification again, once, as for an unknown Page, so a Page moved
1339
+ to another host is forwarded there.
1340
+ - `503 { error: { code: "FEDERATION_NOT_CONFIGURED" } }` and `401` are final for
1341
+ that forward (no 24-hour retry) and recorded with their code: the host lacks
1342
+ its audience, or does not trust this issuer, and a retry would not change it.
1343
+ `503 { error: { code: "ISSUER_UNREACHABLE" } }` is retried: the host could not
1344
+ read this issuer's keys for a moment.
1345
+ - `400`, `403`, `410`, `413` and `422` are final (the host read the request and
1346
+ said no).
1347
+
1348
+ Anything else, no answer included, is retried after 30 s, 1, 2, 5, 10, 20 and 40
1349
+ minutes, 1 hour, then every 2 hours, until 24 hours after the notification. A
1350
+ lead Meta delivers twice is forwarded once. Finished rows are kept a week, then
1351
+ deleted.
1352
+
1353
+ The token is signed by the federation issuer with the same keys and audience as
1354
+ a connection token, so the host verifies it with the trust it already has
1355
+ (`RASTRO_FEDERATION_ISSUER`, `RASTRO_FEDERATION_AUDIENCE`). Its names are
1356
+ exported by the package as `META_LEADGEN_TOKEN` (from `@iann29/rastro`); the
1357
+ control plane signs with them and the host's route checks them, so the two
1358
+ cannot drift apart. Its claims:
1359
+
1360
+ | Claim | Value |
1361
+ | --------------------------- | -------------------------------------------------- |
1362
+ | `iss` | the control plane's federation issuer |
1363
+ | `aud` | the host's deployment URL (the audience) |
1364
+ | `sub` | `rastro:meta-webhook` |
1365
+ | `iat`, `exp`, `jti` | two minutes of life (a host refuses more than ten) |
1366
+ | `rastro_purpose` | `meta_leadgen` |
1367
+ | `rastro_connection_id` | the dashboard connection the Page is routed to |
1368
+ | `rastro_organization_id` | that connection's organization |
1369
+ | `rastro_connection_version` | that connection's version |
1370
+ | `rastro_page_id` | the Page, equal to the body's `pageId` |
1371
+ | `rastro_leadgen_id` | the lead, equal to the body's `leadgenId` |
1372
+
1373
+ It has no `rastro_permissions`, so the federation authorizer refuses it
1374
+ (`FEDERATION_INVALID_CLAIMS`) and it can never be replayed as a read token.
1375
+
1376
+ ### Host side
1377
+
1378
+ The component serves this half; a host turns it on as [Turn it on](#turn-it-on)
1379
+ says, and [How a lead comes in](#how-a-lead-comes-in) walks the route. What this
1380
+ control plane depends on:
1381
+
1382
+ - The `metaLeads` capability and `metaLinkedPages` as a manifest `functions`
1383
+ key: a host that records its functions without it is never read.
1384
+ - `metaLinkedPages({ siteIds })` answers
1385
+ `{ pages: Array<{ pageId; siteId }>; leadgenReady; issuer; audience; handlerReady }`
1386
+ for the Pages linked to those sites only, one site per Page, to any reader
1387
+ token. The control plane requires `audience` to equal the connection's
1388
+ deployment URL.
1389
+ - `POST /rastro/meta/leadgen` (the component mounted with `httpPrefix`
1390
+ `/rastro/`, the only prefix the control plane posts to) verifies the bearer
1391
+ token against the published keys of the issuer `metaLinkedPages` reports and
1392
+ its own audience, with a lifetime of at most
1393
+ `META_LEADGEN_TOKEN.maxLifetimeSeconds`; requires
1394
+ `rastro_purpose === "meta_leadgen"`, because a dashboard member's connection
1395
+ token is a valid JWT of the same issuer and audience and must not be able to
1396
+ ask a host to ingest a lead; requires `rastro_page_id` and `rastro_leadgen_id`
1397
+ to equal the body's `pageId` and `leadgenId`; then schedules the ingestion and
1398
+ answers `202`, or `200 PAGE_NOT_LINKED`, `401`,
1399
+ `503 FEDERATION_NOT_CONFIGURED` (final) or `503 ISSUER_UNREACHABLE` (retried)
1400
+ as [Forwarding](#lead-ads-webhook-control-plane) reads them.
1401
+ - The connection's deployment origin is in
1402
+ `RASTRO_META_LEADS_DEPLOYMENT_ORIGINS`, and a custom-domain site URL in
1403
+ `RASTRO_FEDERATION_ALLOWED_DEPLOYMENT_ORIGINS`.
1404
+
1405
+ ## The Formulários screens (`FormsView`, Lead Ads)
1406
+
1407
+ Lead Ads (instant forms, contract §12) get their own screens in
1408
+ `@iann29/rastro/ui`, pure like `IntegrationsView` and with the same props
1409
+ conventions: `audience` (`"agency"` or `"business"`, the first site only),
1410
+ `productName`, `boardLabel`, `partner`, `otherOwnerLabel` and `permitted`.
1411
+ `@iann29/rastro/ui/sample` has the approved demo in `sampleForms` (Clínica
1412
+ Sorriso, the form "Avaliação gratuita", the leads Mariana Costa and Pedro
1413
+ Alves).
1414
+
1415
+ - `FormsView` lists the connection's Pages (`pages`, from `metaPages`) and the
1416
+ forms of the linked ones (`forms`: `metaForms` answers a site's Pages with
1417
+ their forms nested; `metaFormsRows` flattens the sites' answers into the rows
1418
+ the screen reads, with each form's `leads30d`, `daily`, `lastLeadAt` and
1419
+ `lastLeadVia`). The status line above the Pages reads "Leads chegam na hora"
1420
+ only when the newest lead came with Meta's webhook and the dashboard forwards
1421
+ every linked Page; otherwise "Leads entram em até 15 min" (an unpublished Meta
1422
+ app sends no webhook, so every lead comes with the reconciliation). It says
1423
+ nothing while no Page is linked or a linked Page's leads are held back (its
1424
+ card says why). `wroteFirst` (`{ count, capped? }`) is the host's count of
1425
+ form leads who wrote first on WhatsApp in the 30 days; the summary adds "· 31
1426
+ escreveram primeiro" (`500+` when `capped`). `report` is `formsReport` as the
1427
+ host answers it (`{ forms, partial }`, the columns and discards as arrays, one
1428
+ row per `(siteId, formId)`, read for the site the Page feeds now), for the
1429
+ summary and the drawer. `rules` is `formRules` per form id:
1430
+ `configured: false` is a form that never had rules ("Novo · sem regra"),
1431
+ `undefined` is still loading, and `null` means the host could not answer,
1432
+ which shows the rules as unreadable and blocks saving so an empty draft never
1433
+ overwrites them. A Page without `leadsAllowed` shows "Reconectar a Meta"
1434
+ (`onReconnect`); an unlinked Page offers `onLinkPage(siteId, pageId)`, a
1435
+ linked one `onUnlinkPage` after a confirmation. A linked Page's `lastError`
1436
+ code gets its own short text: only `META_TOKEN_INVALID` and
1437
+ `META_PERMISSION_MISSING` ask to reconnect Meta; `HANDLER_MISSING` (or
1438
+ `handlerReady: false`) says the host still needs its form lead handler; the
1439
+ codes that pass by themselves (`META_TRUNCATED`, `META_RATE_LIMITED`,
1440
+ `META_UNAVAILABLE`, `RUN_TIMEOUT`) never say something is broken. A Page
1441
+ linked to a site outside the reader's grant (`linkedOutsideScope`, the site
1442
+ not named) shows "Ligada a outro cliente/site" and is never offered. A Page or
1443
+ rules another owner set (`own: false`) show "Ligado pelo <otherOwnerLabel>"
1444
+ and stay read only.
1445
+ - `FormRulesDrawer` (a row opens it) edits one form: the entry column (null is
1446
+ the board's own door), the ordered rules (first match wins; drag or arrow keys
1447
+ reorder; `monthCount` per stored rule; a discard keeps its reason on the
1448
+ card), the last 30 days simulated under the draft
1449
+ (`onSimulate(siteId, formId, draft)` asks, debounced, with the entry and the
1450
+ complete rules only, so editing the first message never asks again;
1451
+ `simulation` is `simulateFormRules`' answer, whose `columns` already hold the
1452
+ entry column's leads when the draft names one) and how the first contact
1453
+ happens: the lead writes first (recommended, the button's state), one click by
1454
+ a person (always on, the suggested text with `{primeiro_nome}`) or the
1455
+ automatic send, which turns on only behind the risk checkbox and keeps its
1456
+ brakes (wait at least 15 min, a maximum per hour, business hours; the random
1457
+ interval between sends is fixed). Saving calls
1458
+ `onSaveRules(siteId, formId, rules)` with `setFormRules`'s shape; the drawer
1459
+ closes when that resolves to anything but `false`, and a refusal keeps the
1460
+ draft open beside `error`. It changes only the next leads. When Meta does not
1461
+ say whether the button is there (`whatsappButton: null`), the company marks
1462
+ "Já liguei": that is `whatsappButtonConfirmed`, saved on its own and read back
1463
+ from `formRules`; without the automatic send, who writes first follows it
1464
+ (`lead_starts` with the button, `one_click` without).
1465
+ - `WhatsAppButtonGuide` (the drawer's "Ver como ligar") walks the four steps in
1466
+ Meta's form editor and lists what can be checked: the published copy and its
1467
+ button from the Page's forms, plus the host's own `guideChecks`.
1468
+ `onRefreshForms(siteId)` is its "Conferir de novo".
1469
+ - `FormLeadBadge` ("Formulário · Avaliação gratuita", or "veio do formulário")
1470
+ and `FormLeadAnswers` (answers, origin and the path the lead followed, as a
1471
+ `card`, a `panel` or the phone's `compact`) are for the host's card and
1472
+ contact panel. The host passes what it holds: Rastro never stores a form
1473
+ lead's name, phone, e-mail or free-text answers.
1474
+ `formLeadFromJourney(journey)` turns a lead journey's `form` (`leads.journey`:
1475
+ origin, choice answers, `rulePath`, decision) into their `FormLead`;
1476
+ Campanhas' lead drawer uses it to show the badge and the answers whenever
1477
+ `journey.form` is there.
1478
+
1479
+ Each piece reads its own width with container queries: the list turns into one
1480
+ button per form under 860px, and the drawer into the phone layout under 640px.
1481
+
1482
+ The dashboard shows a "Formulários" tab in Integrações only for a host that
1483
+ advertises `metaLeads` and lists `metaPages`. It calls the federated functions
1484
+ by the names of `FEDERATED_ANALYTICS_META_LEAD_FUNCTIONS`: `metaPages`,
1485
+ `metaForms` (one call per site, with `now`), `metaFormRules`,
1486
+ `simulateMetaFormRules` (the entry, the rules and `now`; no first contact),
1487
+ `metaFormsReport` (reads) and `linkMetaPage`, `unlinkMetaPage`,
1488
+ `setMetaFormRules` (with `whatsappButtonConfirmed`), `refreshMetaForms` (writes,
1489
+ behind `analytics:configure`); the report, the simulation and "Conferir de novo"
1490
+ each wait for the host to list their function. The host's 30-day reads take the
1491
+ end of the current UTC day as `now`, and the report a 30-day window, both
1492
+ following the dashboard's clock; the host counts the report in whole UTC days
1493
+ (see `formsReport` above), so a window aligned on UTC days matches the rows'
1494
+ `leads30d`. Right after a Page is linked or unlinked, the dashboard asks its
1495
+ control plane to refresh that connection's Page routes
1496
+ (`metaPageRoutes.refreshConnection`), so the first lead of a new link is routed
1497
+ without waiting. A form whose rules the host could not read stays unreadable
1498
+ rather than empty. Each site's board comes from the columns its host declared
1499
+ with `setPipeline`. `returnMetaFormLead` (sending a discarded lead back) has no
1500
+ button on the dashboard yet.
1501
+
1502
+ In Campanhas, a form an ad ran arrives with origin `lead_form`, which counts as
1503
+ paid next to `ctwa_ad` (it is never listed among the organic origins), and its
1504
+ line reads "Formulário · <ad>"; a form filled without an ad arrives as
1505
+ `lead_form_organic`, "Formulário instantâneo". Both enter through the
1506
+ `lead_form` entry point, and the journey starts with "Enviou o formulário do
1507
+ anúncio" (or "Enviou um formulário instantâneo"). Meta's short platform keys
1508
+ (`ig`, `fb`) read as the app's name. The Vendas line, and the portfolio's
1509
+ sparklines and week alerts, read `leadsDaily` with `origin: "paid"` (every lead
1510
+ an ad bought, against the same spend) on a host whose manifest carries
1511
+ `leadsPaid` (or `metaLeads`), and `"ctwa_ad"` on a host of an older release.