@iann29/rastro 0.10.8 → 0.11.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (182) hide show
  1. package/README.md +8 -0
  2. package/agent/integration.md +45 -0
  3. package/agent/manifest.json +4 -4
  4. package/dist/client/federation.d.ts +82 -9
  5. package/dist/client/federation.d.ts.map +1 -1
  6. package/dist/client/federation.js +40 -2
  7. package/dist/client/federation.js.map +1 -1
  8. package/dist/client/index.d.ts +464 -8
  9. package/dist/client/index.d.ts.map +1 -1
  10. package/dist/client/index.js +259 -7
  11. package/dist/client/index.js.map +1 -1
  12. package/dist/component/_generated/api.d.ts +10 -0
  13. package/dist/component/_generated/api.d.ts.map +1 -1
  14. package/dist/component/_generated/api.js.map +1 -1
  15. package/dist/component/_generated/component.d.ts +273 -21
  16. package/dist/component/_generated/component.d.ts.map +1 -1
  17. package/dist/component/_generated/server.d.ts +2 -0
  18. package/dist/component/_generated/server.d.ts.map +1 -1
  19. package/dist/component/_generated/server.js.map +1 -1
  20. package/dist/component/constants.d.ts +31 -0
  21. package/dist/component/constants.d.ts.map +1 -1
  22. package/dist/component/constants.js +31 -0
  23. package/dist/component/constants.js.map +1 -1
  24. package/dist/component/convex.config.d.ts +2 -0
  25. package/dist/component/convex.config.js +7 -0
  26. package/dist/component/convex.config.js.map +1 -1
  27. package/dist/component/errors.d.ts +1 -1
  28. package/dist/component/errors.d.ts.map +1 -1
  29. package/dist/component/errors.js.map +1 -1
  30. package/dist/component/http.d.ts.map +1 -1
  31. package/dist/component/http.js +64 -0
  32. package/dist/component/http.js.map +1 -1
  33. package/dist/component/leadAdsValidators.d.ts +1092 -0
  34. package/dist/component/leadAdsValidators.d.ts.map +1 -0
  35. package/dist/component/leadAdsValidators.js +304 -0
  36. package/dist/component/leadAdsValidators.js.map +1 -0
  37. package/dist/component/leadFormRules.d.ts +60 -0
  38. package/dist/component/leadFormRules.d.ts.map +1 -0
  39. package/dist/component/leadFormRules.js +140 -0
  40. package/dist/component/leadFormRules.js.map +1 -0
  41. package/dist/component/leadFormStore.d.ts +16 -0
  42. package/dist/component/leadFormStore.d.ts.map +1 -0
  43. package/dist/component/leadFormStore.js +92 -0
  44. package/dist/component/leadFormStore.js.map +1 -0
  45. package/dist/component/leadgenToken.d.ts +56 -0
  46. package/dist/component/leadgenToken.d.ts.map +1 -0
  47. package/dist/component/leadgenToken.js +262 -0
  48. package/dist/component/leadgenToken.js.map +1 -0
  49. package/dist/component/leads.d.ts +76 -7
  50. package/dist/component/leads.d.ts.map +1 -1
  51. package/dist/component/leads.js +112 -78
  52. package/dist/component/leads.js.map +1 -1
  53. package/dist/component/meta.d.ts.map +1 -1
  54. package/dist/component/meta.js +21 -1
  55. package/dist/component/meta.js.map +1 -1
  56. package/dist/component/metaCapi.d.ts.map +1 -1
  57. package/dist/component/metaCapi.js +10 -0
  58. package/dist/component/metaCapi.js.map +1 -1
  59. package/dist/component/metaFake.d.ts +44 -0
  60. package/dist/component/metaFake.d.ts.map +1 -1
  61. package/dist/component/metaFake.js +400 -1
  62. package/dist/component/metaFake.js.map +1 -1
  63. package/dist/component/metaGraph.d.ts +91 -0
  64. package/dist/component/metaGraph.d.ts.map +1 -1
  65. package/dist/component/metaGraph.js +217 -0
  66. package/dist/component/metaGraph.js.map +1 -1
  67. package/dist/component/metaLeads.d.ts +681 -0
  68. package/dist/component/metaLeads.d.ts.map +1 -0
  69. package/dist/component/metaLeads.js +2634 -0
  70. package/dist/component/metaLeads.js.map +1 -0
  71. package/dist/component/schema.d.ts +414 -5
  72. package/dist/component/schema.d.ts.map +1 -1
  73. package/dist/component/schema.js +169 -1
  74. package/dist/component/schema.js.map +1 -1
  75. package/dist/component/validators.d.ts +235 -11
  76. package/dist/component/validators.d.ts.map +1 -1
  77. package/dist/component/validators.js +14 -1
  78. package/dist/component/validators.js.map +1 -1
  79. package/dist/tracker/generated.d.ts +1 -1
  80. package/dist/tracker/generated.js +1 -1
  81. package/dist/ui/campaigns/CampaignsView.d.ts +5 -1
  82. package/dist/ui/campaigns/CampaignsView.d.ts.map +1 -1
  83. package/dist/ui/campaigns/CampaignsView.js.map +1 -1
  84. package/dist/ui/campaigns/Drawer.d.ts.map +1 -1
  85. package/dist/ui/campaigns/Drawer.js +8 -26
  86. package/dist/ui/campaigns/Drawer.js.map +1 -1
  87. package/dist/ui/campaigns/labels.d.ts +29 -1
  88. package/dist/ui/campaigns/labels.d.ts.map +1 -1
  89. package/dist/ui/campaigns/labels.js +77 -2
  90. package/dist/ui/campaigns/labels.js.map +1 -1
  91. package/dist/ui/campaigns/model.js +2 -2
  92. package/dist/ui/campaigns/model.js.map +1 -1
  93. package/dist/ui/forms/FirstContact.d.ts +19 -0
  94. package/dist/ui/forms/FirstContact.d.ts.map +1 -0
  95. package/dist/ui/forms/FirstContact.js +81 -0
  96. package/dist/ui/forms/FirstContact.js.map +1 -0
  97. package/dist/ui/forms/FormLead.d.ts +28 -0
  98. package/dist/ui/forms/FormLead.d.ts.map +1 -0
  99. package/dist/ui/forms/FormLead.js +199 -0
  100. package/dist/ui/forms/FormLead.js.map +1 -0
  101. package/dist/ui/forms/FormRulesDrawer.d.ts +49 -0
  102. package/dist/ui/forms/FormRulesDrawer.d.ts.map +1 -0
  103. package/dist/ui/forms/FormRulesDrawer.js +149 -0
  104. package/dist/ui/forms/FormRulesDrawer.js.map +1 -0
  105. package/dist/ui/forms/FormsView.d.ts +10 -0
  106. package/dist/ui/forms/FormsView.d.ts.map +1 -0
  107. package/dist/ui/forms/FormsView.js +215 -0
  108. package/dist/ui/forms/FormsView.js.map +1 -0
  109. package/dist/ui/forms/Icons.d.ts +46 -0
  110. package/dist/ui/forms/Icons.d.ts.map +1 -0
  111. package/dist/ui/forms/Icons.js +45 -0
  112. package/dist/ui/forms/Icons.js.map +1 -0
  113. package/dist/ui/forms/RuleRow.d.ts +37 -0
  114. package/dist/ui/forms/RuleRow.d.ts.map +1 -0
  115. package/dist/ui/forms/RuleRow.js +80 -0
  116. package/dist/ui/forms/RuleRow.js.map +1 -0
  117. package/dist/ui/forms/WhatsAppButtonGuide.d.ts +33 -0
  118. package/dist/ui/forms/WhatsAppButtonGuide.d.ts.map +1 -0
  119. package/dist/ui/forms/WhatsAppButtonGuide.js +90 -0
  120. package/dist/ui/forms/WhatsAppButtonGuide.js.map +1 -0
  121. package/dist/ui/forms/index.d.ts +11 -0
  122. package/dist/ui/forms/index.d.ts.map +1 -0
  123. package/dist/ui/forms/index.js +7 -0
  124. package/dist/ui/forms/index.js.map +1 -0
  125. package/dist/ui/forms/journey.d.ts +19 -0
  126. package/dist/ui/forms/journey.d.ts.map +1 -0
  127. package/dist/ui/forms/journey.js +54 -0
  128. package/dist/ui/forms/journey.js.map +1 -0
  129. package/dist/ui/forms/model.d.ts +239 -0
  130. package/dist/ui/forms/model.d.ts.map +1 -0
  131. package/dist/ui/forms/model.js +556 -0
  132. package/dist/ui/forms/model.js.map +1 -0
  133. package/dist/ui/forms/sample.d.ts +36 -0
  134. package/dist/ui/forms/sample.d.ts.map +1 -0
  135. package/dist/ui/forms/sample.js +444 -0
  136. package/dist/ui/forms/sample.js.map +1 -0
  137. package/dist/ui/forms/types.d.ts +454 -0
  138. package/dist/ui/forms/types.d.ts.map +1 -0
  139. package/dist/ui/forms/types.js +2 -0
  140. package/dist/ui/forms/types.js.map +1 -0
  141. package/dist/ui/index.d.ts +2 -0
  142. package/dist/ui/index.d.ts.map +1 -1
  143. package/dist/ui/index.js +4 -0
  144. package/dist/ui/index.js.map +1 -1
  145. package/dist/ui/integrations/Overlay.d.ts +3 -1
  146. package/dist/ui/integrations/Overlay.d.ts.map +1 -1
  147. package/dist/ui/integrations/Overlay.js +2 -2
  148. package/dist/ui/integrations/Overlay.js.map +1 -1
  149. package/dist/ui/portfolio/PortfolioView.d.ts +4 -1
  150. package/dist/ui/portfolio/PortfolioView.d.ts.map +1 -1
  151. package/dist/ui/portfolio/PortfolioView.js.map +1 -1
  152. package/dist/ui/sample.d.ts +3 -1
  153. package/dist/ui/sample.d.ts.map +1 -1
  154. package/dist/ui/sample.js +2 -0
  155. package/dist/ui/sample.js.map +1 -1
  156. package/dist/ui/seed.d.ts +1 -1
  157. package/dist/ui/ui.css +2301 -0
  158. package/docs/federation.md +19 -0
  159. package/docs/meta-ads.md +713 -7
  160. package/docs/upgrading.md +75 -0
  161. package/llms.txt +1 -0
  162. package/package.json +1 -1
  163. package/src/component/_generated/api.ts +10 -0
  164. package/src/component/_generated/component.ts +289 -20
  165. package/src/component/_generated/server.ts +2 -0
  166. package/src/component/constants.ts +33 -0
  167. package/src/component/convex.config.ts +7 -0
  168. package/src/component/errors.ts +3 -1
  169. package/src/component/http.ts +75 -0
  170. package/src/component/leadAdsValidators.ts +365 -0
  171. package/src/component/leadFormRules.ts +202 -0
  172. package/src/component/leadFormStore.ts +129 -0
  173. package/src/component/leadgenToken.ts +348 -0
  174. package/src/component/leads.ts +136 -84
  175. package/src/component/meta.ts +21 -1
  176. package/src/component/metaCapi.ts +13 -1
  177. package/src/component/metaFake.ts +485 -1
  178. package/src/component/metaGraph.ts +327 -0
  179. package/src/component/metaLeads.ts +3101 -0
  180. package/src/component/schema.ts +187 -1
  181. package/src/component/validators.ts +17 -1
  182. package/src/tracker/generated.ts +1 -1
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,695 @@ 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` and a `rules` summary. At most 500 forms a site in all, every
1040
+ Page's `ACTIVE` forms first, then the others while room is left (Meta keeps
1041
+ archived forms forever). New forms show up by themselves on every
1042
+ reconciliation run; `refreshForms({ siteId, pageId? })` (action) reads them at
1043
+ once.
1044
+ - `setFormRules({ siteId, formId, entryColumn, rules, firstContact, whatsappButtonConfirmed?, ownerKey?, onlyOwn? })`
1045
+ (mutation) replaces the form's rules. Each rule is
1046
+ `{ questionKey, answers, to }`: `questionKey` is a choice question of the
1047
+ form, `answers` name its options (by key or shown value; stored by key) and
1048
+ `to` is `{ column }` or `{ discard: reason }`. The first matching rule
1049
+ decides; with none, the lead goes to `entryColumn` (`null`: the host's own
1050
+ entry). Columns must be on the site's pipeline (`setPipeline`).
1051
+ `firstContact.mode` is `lead_starts`, `one_click` (needs `text`;
1052
+ `{primeiro_nome}` is the host's to fill) or `automatic` (needs `brakes`: wait
1053
+ 15–1440 minutes, 1–120 an hour, business hours or not). Editing decides the
1054
+ next leads only. At most 20 rules.
1055
+ - `formRules({ siteId, formId, now?, ownerKey? })` (query): the rules, each with
1056
+ `monthCount` (the leads it decided in the 30 days up to `now`; a rule's count
1057
+ follows its question, answers and target, so reordering keeps it and editing
1058
+ starts a new one), `entryColumn`, `firstContact`, `configured` and, with an
1059
+ owner, `own`.
1060
+ - `simulateFormRules({ siteId, formId, entryColumn, rules, now })` (query): the
1061
+ last 30 days' leads under a draft: per rule, per column, per discard reason,
1062
+ and those left to the entry. It reads the choice answers Rastro keeps, never
1063
+ Meta. A rule still being written (no answer yet) counts zero. `entry` counts
1064
+ the leads no rule matched; when the draft names an `entryColumn`, `columns`
1065
+ counts them under that column too, so a screen adds `entry` only when no
1066
+ `columns` row names the entry (unlike `formsReport`, whose `entry` is only the
1067
+ leads sent to the host's own entry).
1068
+ - `returnFormLead({ siteId, leadId, ownerKey? })` (mutation) sends a discarded
1069
+ lead back to the funnel: its decision becomes the host's entry and the reason
1070
+ stays as history (`returned`). The host moves its card itself.
1071
+ - `formsReport({ siteIds, from, to })` (query): per form, over the cohort days
1072
+ in the range, `leads`, `qualified`, `won`, `revenueCents` (from the lead
1073
+ rollups), the leads sent to each column, to the `entry`, `discarded` per
1074
+ reason, and `returned`. `partial` when a read cap was hit. There is one row
1075
+ per `(siteId, formId)`: a Page moved from one site to another inside the range
1076
+ has a row under each, so key rows by both and read the one of the site the
1077
+ Page feeds now. The range is whole UTC days: `from` floors to its day and `to`
1078
+ takes in the whole UTC day it falls on, so a 30-day window is
1079
+ `to = end of a UTC day`, `from = to + 1 − 30 days`.
1080
+
1081
+ ### How a lead comes in
1082
+
1083
+ 1. Meta posts the `leadgen` webhook to the Rastro dashboard's control plane,
1084
+ which knows which host links the Page (`metaLinkedPages`) and forwards
1085
+ `{ pageId, leadgenId }` to `POST <host site>/rastro/meta/leadgen`.
1086
+ 2. The component's route checks the control plane's token: RS256 under the
1087
+ issuer's published keys (`<issuer>/.well-known/openid-configuration`), issuer
1088
+ and audience as the host trusts them, subject `rastro:meta-webhook`, at most
1089
+ 10 minutes of lifetime (the control plane signs 2), purpose `meta_leadgen`,
1090
+ naming its connection, organization and connection version, and bound to
1091
+ exactly that Page and leadgen id. The names come from `META_LEADGEN_TOKEN`
1092
+ (exported by the package; the control plane signs with the same object,
1093
+ contract §12.3). Then it schedules the ingestion and answers `202`
1094
+ `{ accepted: true }`; a Page this host does not link answers `200`
1095
+ `{ accepted: false, reason: "PAGE_NOT_LINKED" }`, a bad token `401`
1096
+ `{ error: { code: "UNAUTHENTICATED" } }`, a host without the audience env
1097
+ `503` `{ error: { code: "FEDERATION_NOT_CONFIGURED" } }`, and an issuer whose
1098
+ keys cannot be read right now (unreachable, `5xx`, `429`, not JSON), or whose
1099
+ keys were read less than a minute ago and lack the token's key id (a key
1100
+ rotated meanwhile), `503` `{ error: { code: "ISSUER_UNREACHABLE" } }`, which
1101
+ is retried. The control plane reads these bodies (see
1102
+ [Forwarding](#lead-ads-webhook-control-plane)).
1103
+ 3. The ingestion reads the lead from Meta with the Page token
1104
+ (`GET /{leadgen}?fields=created_time,field_data,ad_id,adset_id,campaign_id,form_id,platform,is_organic`),
1105
+ splits its answers (identity and free ones in memory only), applies the
1106
+ rules, records the lead and calls the handler. Idempotent on the leadgen id.
1107
+ 4. A scheduler chain (no cron) reads every linked Page every 15 minutes: the
1108
+ Page's forms, then each active form's leads since its cursor
1109
+ (`GET /{form}/leads` filtered on `time_created`), oldest first, through the
1110
+ same ingestion. Meta lists a form's leads newest first and one listing stops
1111
+ at 2,000, so a capped listing is never trusted: the run halves the window
1112
+ (`time_created` between the cursor and an end) until Meta lists it whole,
1113
+ ingests it and moves the cursor to its end, then reads the next window (12
1114
+ listings a form a run at most; a busy backfill goes on next run, with
1115
+ `META_TRUNCATED` shown meanwhile). Rate limits push the next run back an
1116
+ hour; other failures retry after 15 minutes, doubling up to 6 hours. It never
1117
+ uses `paginate()`.
1118
+ 5. A lead that cannot come in holds its form's cursor there (the handler
1119
+ missing, or throwing); the leads after it are still ingested and skipped by
1120
+ their leadgen id when read again. Each failed commit is counted by leadgen id
1121
+ in `formLeadFailures` (ids, attempts and the error's code, never its
1122
+ message). A lead that failed 5 times while later leads of its form came in is
1123
+ given up on: the cursor moves past it, the link shows
1124
+ `HANDLER_GAVE_UP: … skipped after 5 failed attempts (first: <leadgenId>)` for
1125
+ that run and the row keeps `skippedAt`. While every lead fails (the host's
1126
+ CRM is down) none is given up on. The host's error message is never logged
1127
+ (it may carry the contact): only a `ConvexError`'s `data.code` or the error's
1128
+ class name.
1129
+ 6. A lead the host removes (`leads.remove`) leaves its leadgen id in
1130
+ `formLeadTombstones` (nothing else), so no later read (the webhook retried,
1131
+ the reconciliation, a new link's backfill) brings it back.
1132
+
1133
+ ### Reports
1134
+
1135
+ A form lead's origin is `lead_form` when an ad brought it and
1136
+ `lead_form_organic` when the form was filled without one. `lead_form` counts as
1137
+ paid: it is in `bySite[].paid` beside `ctwa_ad`, and its ad's row in the
1138
+ campaign tree counts it. Spend stays on the `ctwa_ad` origin row, as before;
1139
+ cost per lead and return come from `bySite[].paid`. `leads.daily` takes
1140
+ `origin: "lead_form"` (no spend) and `origin: "paid"` (`ctwa_ad` and `lead_form`
1141
+ together, with the spend that bought them): a sales × spend line asks for
1142
+ `"paid"`. A lead's journey (`leads.journey`) carries `form`: its choice answers
1143
+ with their labels, the origin, the decision, `rulePath` and `returned`.
1144
+
1145
+ ### Federated Lead Ads functions
1146
+
1147
+ With `metaLeads: true` the federated surface serves the forms screen to the
1148
+ Rastro dashboard, acting for the dashboard connection's own Meta owner like the
1149
+ rest of the Meta surface.
1150
+
1151
+ | Function | Kind | Needs |
1152
+ | ----------------------- | -------- | --------- |
1153
+ | `metaPages` | query | reader |
1154
+ | `metaForms` | query | reader |
1155
+ | `metaFormRules` | query | reader |
1156
+ | `simulateMetaFormRules` | query | reader |
1157
+ | `metaFormsReport` | query | reader |
1158
+ | `metaLinkedPages` | query | reader |
1159
+ | `linkMetaPage` | action | configure |
1160
+ | `unlinkMetaPage` | action | configure |
1161
+ | `refreshMetaForms` | action | configure |
1162
+ | `setMetaFormRules` | mutation | configure |
1163
+ | `returnMetaFormLead` | mutation | configure |
1164
+
1165
+ A Page linked to the site by another owner of the host, and the rules of its
1166
+ forms, are locked to the dashboard (`CONFLICT`: "Ligado pelo uze.ai"); a Page
1167
+ linked to a site outside the grant reads `linkedOutsideScope: true` without its
1168
+ site. The three actions authorize through `authorizeMetaAction`, which the host
1169
+ already exports. Without `metaLeads` the reads refuse with
1170
+ `FEDERATION_INVALID_SCOPE` ("This host does not bring Meta lead forms").
1171
+ `metaLeads` needs `metaAds`: `exposeFederatedAnalyticsApi` throws at setup when
1172
+ it gets `metaLeads: true` alone, since the dashboard shows the Formulários
1173
+ section inside Integrações and links Pages with the Meta login, both of
1174
+ `metaAds`.
1175
+
1176
+ ```ts
1177
+ export const {
1178
+ // ...
1179
+ metaPages,
1180
+ metaForms,
1181
+ metaFormRules,
1182
+ simulateMetaFormRules,
1183
+ metaFormsReport,
1184
+ metaLinkedPages,
1185
+ linkMetaPage,
1186
+ unlinkMetaPage,
1187
+ refreshMetaForms,
1188
+ setMetaFormRules,
1189
+ returnMetaFormLead,
1190
+ } = federated;
1191
+ ```
1192
+
1193
+ ### Lead Ads in the demo
1194
+
1195
+ With `RASTRO_META_FAKE=true` every login lists a Facebook Page per sample client
1196
+ (Loja X, Clínica Sorriso, Pet Mania, Casa Aurora) plus "Sorriso Kids", whose
1197
+ leads Meta did not grant. Clínica Sorriso has the four forms of the approved
1198
+ screens ("Avaliação gratuita", "Clareamento — orçamento", "Lente de contato
1199
+ dental", archived "Promo setembro"); the others one each. Every active form
1200
+ brings made-up leads at a steady pace (Avaliação gratuita one every 11h15), so a
1201
+ linked Page keeps receiving them as time passes. The example host registers its
1202
+ handler in the demo's Meta step; `demoAgency:seedAgencyForms` links each
1203
+ client's Page with 30 days of leads (never part of `seedDemo`, whose numbers are
1204
+ the screens' sample).
1205
+
1206
+ ## Lead Ads webhook (control plane)
1207
+
1208
+ Meta sends the lead notifications of every Page to one callback per app. For the
1209
+ "Amage Rastro" app that callback is the Rastro control plane, which only routes:
1210
+ it checks Meta's signature, finds which connected host linked the Page, and
1211
+ hands that host the Page id and the lead id. It never sees the lead itself; the
1212
+ host reads it from Meta with its own Page token. What a host must serve for it
1213
+ is under [Host side](#host-side) below.
1214
+
1215
+ **The callback.** `GET` and `POST /meta/webhook` on the control plane's
1216
+ HTTP-actions origin, in production
1217
+ `https://site.api.amagerastro.com/meta/webhook`. Two control-plane env vars:
1218
+
1219
+ - `RASTRO_META_APP_SECRET`: the app secret. Every `POST` must carry
1220
+ `X-Hub-Signature-256: sha256=<hex>`, the HMAC-SHA256 of the raw body under
1221
+ this secret, compared in constant time; a missing or wrong signature is `401`
1222
+ and nothing in the body is read. Without the variable, `POST` answers `503`,
1223
+ so Meta keeps the notifications and retries.
1224
+ - `RASTRO_META_WEBHOOK_VERIFY_TOKEN`: a random value typed once in the app's
1225
+ Webhooks setup. `GET` echoes `hub.challenge` only for `hub.mode=subscribe` and
1226
+ this token (constant-time compare); anything else is `403`.
1227
+
1228
+ In the app dashboard: Webhooks, object **Page**, callback URL above, the verify
1229
+ token, field **`leadgen`**. Each host subscribes its Pages to the app when a
1230
+ site links them. A signed `POST` answers `200` as soon as its lead ids are
1231
+ recorded; forwarding runs scheduled, so a slow host never makes Meta retry or
1232
+ disable the subscription. A signed body that is not JSON, or changes other than
1233
+ `leadgen`, are acknowledged and ignored.
1234
+
1235
+ **Which hosts may receive leads.** A host's Page list is its own word, and Page
1236
+ ids are public, so the control plane only takes it from deployments the operator
1237
+ lists in `RASTRO_META_LEADS_DEPLOYMENT_ORIGINS` (exact origins of the
1238
+ connections' deployment URLs, comma-separated). Unset, no host receives leads
1239
+ and an unknown-Page drop logs `META_LEADS_DEPLOYMENT_ORIGINS_UNSET`. A
1240
+ connection off the list is never read, and the routes it held are ignored and
1241
+ then dropped, so it can neither take a Page nor block another host's.
1242
+
1243
+ **Which host owns a Page.** Federation only goes from the dashboard to the host,
1244
+ so the control plane reads the hosts: on a listed connection advertising
1245
+ `metaLeads`, it calls the federated query `metaLinkedPages({ siteIds })` →
1246
+ `{ pages: Array<{ pageId; siteId }>; leadgenReady; issuer; audience }` (ten
1247
+ sites per call, at most the `maxSitesPerConnection` first sites of `listSites`,
1248
+ within 45 seconds per host) with a two-minute token of its own:
1249
+ `analytics:read`, subject `rastro:meta-page-routes`. The answer becomes
1250
+ `metaPageRoutes` rows (one per Page and connection), each keeping the host's
1251
+ `leadgenReady`, `issuer` and `audience`. A Page is routed only when
1252
+ `leadgenReady` is true and `issuer` equals the control plane's own federation
1253
+ issuer; otherwise its route row records why (`HOST_NOT_READY`: the host has no
1254
+ `RASTRO_FEDERATION_AUDIENCE`; `ISSUER_MISMATCH`: the host trusts another
1255
+ dashboard; `AUDIENCE_MISMATCH`: the host's `audience` is not the connection's
1256
+ deployment URL; `HOST_SITE_URL_MISSING` / `HOST_SITE_URL_UNTRUSTED`: the
1257
+ connection has no site origin a forward may be posted to), the reason is logged
1258
+ once per refresh, and no forward is signed for it, so a lead never waits 24
1259
+ hours on a host that would refuse it. The rows are refreshed:
1260
+
1261
+ 1. when the connection is verified ("Verificar novamente"), and when a transfer
1262
+ of it settles;
1263
+ 2. right after the dashboard links or unlinks a Page (`linkMetaPage`,
1264
+ `unlinkMetaPage`): it calls the control plane's
1265
+ `metaPageRoutes.refreshConnection({ connectionId })` (a member of the
1266
+ connection's organization only), which schedules that connection's refresh;
1267
+ 3. every 30 minutes for every listed connection advertising `metaLeads`;
1268
+ 4. when a notification names a Page no route knows: every such host is read by a
1269
+ refresh that started after the notification arrived, so a Page linked seconds
1270
+ before its first lead is found. Notifications arriving while a refresh runs
1271
+ wait for it (as long as it may run: five minutes), refreshes keep 10 seconds
1272
+ between them, and a burst shares one. A notification that still matches
1273
+ nothing is dropped, logged as `META_WEBHOOK_UNKNOWN_PAGE` with the Page id
1274
+ alone; it is routed or dropped even when the refresh fails. A Page that
1275
+ refresh found no host claiming is remembered for ten minutes
1276
+ (`metaUnknownPages`): its next leads are dropped at once instead of reading
1277
+ every host again. A host listing the Page clears it.
1278
+ 5. when a Page two deployments claim has a claim read more than a minute ago:
1279
+ that connection is read again (it may be stale: the Page unlinked on its host
1280
+ without the dashboard hearing of it), and the lead is routed once more after
1281
+ that read.
1282
+
1283
+ A host that cannot be read keeps the routes it had, and so does a connection
1284
+ whose transfer is pending: its leads are recorded and retried
1285
+ (`CONNECTION_TRANSFER_PENDING`) until the transfer settles. A host re-verified
1286
+ without `metaLeads`, taken off the list, revoked or deleted loses them. Several
1287
+ organizations may connect the same host deployment; their routes for one Page
1288
+ are one host, and the oldest connection carries the lead. A Page that two
1289
+ different deployments claim routes to neither and logs `PAGE_ROUTE_CONFLICT` as
1290
+ an error (the host itself already refuses one Page on two sites); the leads stay
1291
+ with Meta, where each host's reconciliation can still read them.
1292
+
1293
+ **What the dashboard shows.**
1294
+ `metaPageRoutes.connectionRouting({ connectionId })` (a query, members of the
1295
+ connection's organization only) answers
1296
+ `{ allowed, pages: Array<{ pageId, issue }> }`: `allowed` is false when the
1297
+ control plane forwards nothing of that host (off the operator's list, without
1298
+ `metaLeads`, not active), and each routed Page's `issue` is its block reason as
1299
+ read now, `CONFLICT` when another deployment claims it too, `HOST_REFUSED` when
1300
+ the host answered the last forward to this connection with `401` although it
1301
+ reports itself ready (a key or `kid` mismatch, a `jwks_uri` that is not the
1302
+ issuer's own; kept in `metaLeadgenRefusals` until a forward is delivered again),
1303
+ or `null`. Formulários puts it on each linked Page (`routeIssue`, `NOT_ROUTED`
1304
+ for a host not forwarded to): the Page card says why its leads arrive every 15
1305
+ minutes instead of at once, and "Leads chegam na hora" is not shown.
1306
+
1307
+ **Forwarding.** Each routed notification is one `metaLeadgenForwards` row (Page
1308
+ id, lead id, connection, site, status; never lead data) and one scheduled
1309
+ attempt:
1310
+
1311
+ ```http
1312
+ POST <host site URL>/rastro/meta/leadgen
1313
+ Authorization: Bearer <token>
1314
+ Content-Type: application/json
1315
+
1316
+ { "pageId": "<page id>", "leadgenId": "<leadgen id>" }
1317
+ ```
1318
+
1319
+ The host site URL is the connection's HTTP-actions URL, and `/rastro/` is the
1320
+ component's `httpPrefix`: the control plane cannot learn a host's mount point,
1321
+ so Lead Ads forwarding requires the component mounted under `/rastro/` (a host
1322
+ under another prefix answers `404` to every forward, retried for 24 hours, and
1323
+ its leads arrive only with the reconciliation). An organization admin types that
1324
+ URL, so before each attempt it must be `https` on the default port and on
1325
+ `.convex.site` or `.synapsepanel.com`, or an origin listed in
1326
+ `RASTRO_FEDERATION_ALLOWED_DEPLOYMENT_ORIGINS` (a custom domain); otherwise the
1327
+ forward fails as `HOST_SITE_URL_UNTRUSTED` and nothing is signed. A token the
1328
+ control plane cannot sign (keys missing or not matching) is retried as
1329
+ `ISSUER_MISCONFIGURED`, not blamed on the host. The control plane reads the
1330
+ host's small JSON answer:
1331
+
1332
+ - `202 { accepted: true }` (any `2xx` that does not say otherwise) is delivered.
1333
+ - `200 { accepted: false, reason: "PAGE_NOT_LINKED" }` is `not_linked`, not
1334
+ delivered: the host no longer links the Page (unlinked, or moved to another
1335
+ host). The control plane refreshes that connection's routes at once and
1336
+ accepts the notification again, once, as for an unknown Page, so a Page moved
1337
+ to another host is forwarded there.
1338
+ - `503 { error: { code: "FEDERATION_NOT_CONFIGURED" } }` and `401` are final for
1339
+ that forward (no 24-hour retry) and recorded with their code: the host lacks
1340
+ its audience, or does not trust this issuer, and a retry would not change it.
1341
+ `503 { error: { code: "ISSUER_UNREACHABLE" } }` is retried: the host could not
1342
+ read this issuer's keys for a moment.
1343
+ - `400`, `403`, `410`, `413` and `422` are final (the host read the request and
1344
+ said no).
1345
+
1346
+ Anything else, no answer included, is retried after 30 s, 1, 2, 5, 10, 20 and 40
1347
+ minutes, 1 hour, then every 2 hours, until 24 hours after the notification. A
1348
+ lead Meta delivers twice is forwarded once. Finished rows are kept a week, then
1349
+ deleted.
1350
+
1351
+ The token is signed by the federation issuer with the same keys and audience as
1352
+ a connection token, so the host verifies it with the trust it already has
1353
+ (`RASTRO_FEDERATION_ISSUER`, `RASTRO_FEDERATION_AUDIENCE`). Its names are
1354
+ exported by the package as `META_LEADGEN_TOKEN` (from `@iann29/rastro`); the
1355
+ control plane signs with them and the host's route checks them, so the two
1356
+ cannot drift apart. Its claims:
1357
+
1358
+ | Claim | Value |
1359
+ | --------------------------- | -------------------------------------------------- |
1360
+ | `iss` | the control plane's federation issuer |
1361
+ | `aud` | the host's deployment URL (the audience) |
1362
+ | `sub` | `rastro:meta-webhook` |
1363
+ | `iat`, `exp`, `jti` | two minutes of life (a host refuses more than ten) |
1364
+ | `rastro_purpose` | `meta_leadgen` |
1365
+ | `rastro_connection_id` | the dashboard connection the Page is routed to |
1366
+ | `rastro_organization_id` | that connection's organization |
1367
+ | `rastro_connection_version` | that connection's version |
1368
+ | `rastro_page_id` | the Page, equal to the body's `pageId` |
1369
+ | `rastro_leadgen_id` | the lead, equal to the body's `leadgenId` |
1370
+
1371
+ It has no `rastro_permissions`, so the federation authorizer refuses it
1372
+ (`FEDERATION_INVALID_CLAIMS`) and it can never be replayed as a read token.
1373
+
1374
+ ### Host side
1375
+
1376
+ The component serves this half; a host turns it on as [Turn it on](#turn-it-on)
1377
+ says, and [How a lead comes in](#how-a-lead-comes-in) walks the route. What this
1378
+ control plane depends on:
1379
+
1380
+ - The `metaLeads` capability and `metaLinkedPages` as a manifest `functions`
1381
+ key: a host that records its functions without it is never read.
1382
+ - `metaLinkedPages({ siteIds })` answers
1383
+ `{ pages: Array<{ pageId; siteId }>; leadgenReady; issuer; audience; handlerReady }`
1384
+ for the Pages linked to those sites only, one site per Page, to any reader
1385
+ token. The control plane requires `audience` to equal the connection's
1386
+ deployment URL.
1387
+ - `POST /rastro/meta/leadgen` (the component mounted with `httpPrefix`
1388
+ `/rastro/`, the only prefix the control plane posts to) verifies the bearer
1389
+ token against the published keys of the issuer `metaLinkedPages` reports and
1390
+ its own audience, with a lifetime of at most
1391
+ `META_LEADGEN_TOKEN.maxLifetimeSeconds`; requires
1392
+ `rastro_purpose === "meta_leadgen"`, because a dashboard member's connection
1393
+ token is a valid JWT of the same issuer and audience and must not be able to
1394
+ ask a host to ingest a lead; requires `rastro_page_id` and `rastro_leadgen_id`
1395
+ to equal the body's `pageId` and `leadgenId`; then schedules the ingestion and
1396
+ answers `202`, or `200 PAGE_NOT_LINKED`, `401`,
1397
+ `503 FEDERATION_NOT_CONFIGURED` (final) or `503 ISSUER_UNREACHABLE` (retried)
1398
+ as [Forwarding](#lead-ads-webhook-control-plane) reads them.
1399
+ - The connection's deployment origin is in
1400
+ `RASTRO_META_LEADS_DEPLOYMENT_ORIGINS`, and a custom-domain site URL in
1401
+ `RASTRO_FEDERATION_ALLOWED_DEPLOYMENT_ORIGINS`.
1402
+
1403
+ ## The Formulários screens (`FormsView`, Lead Ads)
1404
+
1405
+ Lead Ads (instant forms, contract §12) get their own screens in
1406
+ `@iann29/rastro/ui`, pure like `IntegrationsView` and with the same props
1407
+ conventions: `audience` (`"agency"` or `"business"`, the first site only),
1408
+ `productName`, `boardLabel`, `partner`, `otherOwnerLabel` and `permitted`.
1409
+ `@iann29/rastro/ui/sample` has the approved demo in `sampleForms` (Clínica
1410
+ Sorriso, the form "Avaliação gratuita", the leads Mariana Costa and Pedro
1411
+ Alves).
1412
+
1413
+ - `FormsView` lists the connection's Pages (`pages`, from `metaPages`) and the
1414
+ forms of the linked ones (`forms`: `metaForms` answers a site's Pages with
1415
+ their forms nested; `metaFormsRows` flattens the sites' answers into the rows
1416
+ the screen reads, with each form's `leads30d`, `daily` and `lastLeadAt`).
1417
+ `report` is `formsReport` as the host answers it (`{ forms, partial }`, the
1418
+ columns and discards as arrays, one row per `(siteId, formId)`, read for the
1419
+ site the Page feeds now), for the summary and the drawer. `rules` is
1420
+ `formRules` per form id: `configured: false` is a form that never had rules
1421
+ ("Novo · sem regra"), `undefined` is still loading, and `null` means the host
1422
+ could not answer, which shows the rules as unreadable and blocks saving so an
1423
+ empty draft never overwrites them. A Page without `leadsAllowed` shows
1424
+ "Reconectar a Meta" (`onReconnect`); an unlinked Page offers
1425
+ `onLinkPage(siteId, pageId)`, a linked one `onUnlinkPage` after a
1426
+ confirmation. A linked Page's `lastError` code gets its own short text: only
1427
+ `META_TOKEN_INVALID` and `META_PERMISSION_MISSING` ask to reconnect Meta;
1428
+ `HANDLER_MISSING` (or `handlerReady: false`) says the host still needs its
1429
+ form lead handler; the codes that pass by themselves (`META_TRUNCATED`,
1430
+ `META_RATE_LIMITED`, `META_UNAVAILABLE`, `RUN_TIMEOUT`) never say something is
1431
+ broken. A Page linked to a site outside the reader's grant
1432
+ (`linkedOutsideScope`, the site not named) shows "Ligada a outro cliente/site"
1433
+ and is never offered. A Page or rules another owner set (`own: false`) show
1434
+ "Ligado pelo <otherOwnerLabel>" and stay read only.
1435
+ - `FormRulesDrawer` (a row opens it) edits one form: the entry column (null is
1436
+ the board's own door), the ordered rules (first match wins; drag or arrow keys
1437
+ reorder; `monthCount` per stored rule; a discard keeps its reason on the
1438
+ card), the last 30 days simulated under the draft
1439
+ (`onSimulate(siteId, formId, draft)` asks, debounced, with the entry and the
1440
+ complete rules only, so editing the first message never asks again;
1441
+ `simulation` is `simulateFormRules`' answer, whose `columns` already hold the
1442
+ entry column's leads when the draft names one) and how the first contact
1443
+ happens: the lead writes first (recommended, the button's state), one click by
1444
+ a person (always on, the suggested text with `{primeiro_nome}`) or the
1445
+ automatic send, which turns on only behind the risk checkbox and keeps its
1446
+ brakes (wait at least 15 min, a maximum per hour, business hours; the random
1447
+ interval between sends is fixed). Saving calls
1448
+ `onSaveRules(siteId, formId, rules)` with `setFormRules`'s shape; the drawer
1449
+ closes when that resolves to anything but `false`, and a refusal keeps the
1450
+ draft open beside `error`. It changes only the next leads. When Meta does not
1451
+ say whether the button is there (`whatsappButton: null`), the company marks
1452
+ "Já liguei": that is `whatsappButtonConfirmed`, saved on its own and read back
1453
+ from `formRules`; without the automatic send, who writes first follows it
1454
+ (`lead_starts` with the button, `one_click` without).
1455
+ - `WhatsAppButtonGuide` (the drawer's "Ver como ligar") walks the four steps in
1456
+ Meta's form editor and lists what can be checked: the published copy and its
1457
+ button from the Page's forms, plus the host's own `guideChecks`.
1458
+ `onRefreshForms(siteId)` is its "Conferir de novo".
1459
+ - `FormLeadBadge` ("Formulário · Avaliação gratuita", or "veio do formulário")
1460
+ and `FormLeadAnswers` (answers, origin and the path the lead followed, as a
1461
+ `card`, a `panel` or the phone's `compact`) are for the host's card and
1462
+ contact panel. The host passes what it holds: Rastro never stores a form
1463
+ lead's name, phone, e-mail or free-text answers.
1464
+ `formLeadFromJourney(journey)` turns a lead journey's `form` (`leads.journey`:
1465
+ origin, choice answers, `rulePath`, decision) into their `FormLead`;
1466
+ Campanhas' lead drawer uses it to show the badge and the answers whenever
1467
+ `journey.form` is there.
1468
+
1469
+ Each piece reads its own width with container queries: the list turns into one
1470
+ button per form under 860px, and the drawer into the phone layout under 640px.
1471
+
1472
+ The dashboard shows a "Formulários" tab in Integrações only for a host that
1473
+ advertises `metaLeads` and lists `metaPages`. It calls the federated functions
1474
+ by the names of `FEDERATED_ANALYTICS_META_LEAD_FUNCTIONS`: `metaPages`,
1475
+ `metaForms` (one call per site, with `now`), `metaFormRules`,
1476
+ `simulateMetaFormRules` (the entry, the rules and `now`; no first contact),
1477
+ `metaFormsReport` (reads) and `linkMetaPage`, `unlinkMetaPage`,
1478
+ `setMetaFormRules` (with `whatsappButtonConfirmed`), `refreshMetaForms` (writes,
1479
+ behind `analytics:configure`); the report, the simulation and "Conferir de novo"
1480
+ each wait for the host to list their function. The host's 30-day reads take the
1481
+ end of the current UTC day as `now`, and the report a 30-day window, both
1482
+ following the dashboard's clock; the host counts the report in whole UTC days
1483
+ (see `formsReport` above), so a window aligned on UTC days matches the rows'
1484
+ `leads30d`. Right after a Page is linked or unlinked, the dashboard asks its
1485
+ control plane to refresh that connection's Page routes
1486
+ (`metaPageRoutes.refreshConnection`), so the first lead of a new link is routed
1487
+ without waiting. A form whose rules the host could not read stays unreadable
1488
+ rather than empty. Each site's board comes from the columns its host declared
1489
+ with `setPipeline`. `returnMetaFormLead` (sending a discarded lead back) has no
1490
+ button on the dashboard yet.
1491
+
1492
+ In Campanhas, a form an ad ran arrives with origin `lead_form`, which counts as
1493
+ paid next to `ctwa_ad` (it is never listed among the organic origins), and its
1494
+ line reads "Formulário · <ad>"; a form filled without an ad arrives as
1495
+ `lead_form_organic`, "Formulário instantâneo". Both enter through the
1496
+ `lead_form` entry point, and the journey starts with "Enviou o formulário do
1497
+ anúncio" (or "Enviou um formulário instantâneo"). Meta's short platform keys
1498
+ (`ig`, `fb`) read as the app's name. The Vendas line, and the portfolio's
1499
+ sparklines and week alerts, read `leadsDaily` with `origin: "paid"` (every lead
1500
+ an ad bought, against the same spend) on a host whose manifest carries
1501
+ `leadsPaid` (or `metaLeads`), and `"ctwa_ad"` on a host of an older release.