@vantageos/backend-doctor 0.1.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 (108) hide show
  1. package/README.md +88 -0
  2. package/dist/cli.d.ts +2 -0
  3. package/dist/cli.js +94 -0
  4. package/dist/contract.d.ts +18 -0
  5. package/dist/contract.js +29 -0
  6. package/dist/coverage-cli.d.ts +2 -0
  7. package/dist/coverage-cli.js +17 -0
  8. package/dist/coverage.d.ts +32 -0
  9. package/dist/coverage.js +77 -0
  10. package/dist/detectors/oracle-axis1.d.ts +86 -0
  11. package/dist/detectors/oracle-axis1.js +221 -0
  12. package/dist/detectors/oracle-axis2.d.ts +22 -0
  13. package/dist/detectors/oracle-axis2.js +149 -0
  14. package/dist/detectors/predicates.d.ts +19 -0
  15. package/dist/detectors/predicates.js +334 -0
  16. package/dist/detectors/shared.d.ts +56 -0
  17. package/dist/detectors/shared.js +103 -0
  18. package/dist/detectors/source/_scan.d.ts +43 -0
  19. package/dist/detectors/source/_scan.js +82 -0
  20. package/dist/detectors/source/backfill.d.ts +27 -0
  21. package/dist/detectors/source/backfill.js +211 -0
  22. package/dist/detectors/source/contract.d.ts +33 -0
  23. package/dist/detectors/source/contract.js +385 -0
  24. package/dist/detectors/source/identity.d.ts +47 -0
  25. package/dist/detectors/source/identity.js +410 -0
  26. package/dist/detectors/source/jobs.d.ts +10 -0
  27. package/dist/detectors/source/jobs.js +209 -0
  28. package/dist/detectors/source/lifecycle.d.ts +6 -0
  29. package/dist/detectors/source/lifecycle.js +147 -0
  30. package/dist/detectors/source/readbudget.d.ts +6 -0
  31. package/dist/detectors/source/readbudget.js +133 -0
  32. package/dist/detectors/source/refusal.d.ts +6 -0
  33. package/dist/detectors/source/refusal.js +125 -0
  34. package/dist/detectors/source/security.d.ts +37 -0
  35. package/dist/detectors/source/security.js +392 -0
  36. package/dist/detectors/source/writesafety.d.ts +14 -0
  37. package/dist/detectors/source/writesafety.js +345 -0
  38. package/dist/detectors/standard/_doc.d.ts +66 -0
  39. package/dist/detectors/standard/_doc.js +152 -0
  40. package/dist/detectors/standard/r41-frontier.d.ts +58 -0
  41. package/dist/detectors/standard/r41-frontier.js +176 -0
  42. package/dist/detectors/standard/r46-returns-validator.d.ts +61 -0
  43. package/dist/detectors/standard/r46-returns-validator.js +474 -0
  44. package/dist/detectors/standard/standard-doc.d.ts +12 -0
  45. package/dist/detectors/standard/standard-doc.js +271 -0
  46. package/dist/detectors/structural.d.ts +14 -0
  47. package/dist/detectors/structural.js +83 -0
  48. package/dist/engine-types.d.ts +10 -0
  49. package/dist/engine-types.js +1 -0
  50. package/dist/not-checkable.d.ts +22 -0
  51. package/dist/not-checkable.js +37 -0
  52. package/dist/oracle.d.ts +33 -0
  53. package/dist/oracle.js +133 -0
  54. package/dist/registry.d.ts +11 -0
  55. package/dist/registry.js +140 -0
  56. package/dist/report.d.ts +35 -0
  57. package/dist/report.js +138 -0
  58. package/dist/severity.d.ts +30 -0
  59. package/dist/severity.js +121 -0
  60. package/dist/tally.d.ts +57 -0
  61. package/dist/tally.js +152 -0
  62. package/dist/trigger.d.ts +47 -0
  63. package/dist/trigger.js +143 -0
  64. package/fixtures/backend-standard-51ec7af1.md +648 -0
  65. package/fixtures/backend-standard-a9d7877b.md +629 -0
  66. package/fixtures/mcp-app-standard-a74a042b.md +279 -0
  67. package/fixtures/mcp-extension-standard-a74a042b.md +259 -0
  68. package/fixtures/mcp-server-standard-a74a042b.md +434 -0
  69. package/fixtures/mcp-standard-a74a042b.md +354 -0
  70. package/fixtures/vp-1200-green/.pin +1 -0
  71. package/fixtures/vp-1200-green/convex/migrations.ts +350 -0
  72. package/fixtures/vp-1200-green/convex/schema.ts +1243 -0
  73. package/fixtures/vp-1200-red/.pin +1 -0
  74. package/fixtures/vp-1200-red/convex/migrations.ts +239 -0
  75. package/fixtures/vp-1200-red/convex/schema.ts +1243 -0
  76. package/fixtures/vp-by-tool-day158.csv +190 -0
  77. package/fixtures/vp-convex/.github/workflows/npm-publish.yml +163 -0
  78. package/fixtures/vp-convex/.pin +1 -0
  79. package/fixtures/vp-convex/bun.lock +783 -0
  80. package/fixtures/vp-convex/convex/auth.config.ts +8 -0
  81. package/fixtures/vp-convex/convex/briefingNotes.ts +321 -0
  82. package/fixtures/vp-convex/convex/convex.config.ts +14 -0
  83. package/fixtures/vp-convex/convex/credentials.ts +591 -0
  84. package/fixtures/vp-convex/convex/crons.ts +72 -0
  85. package/fixtures/vp-convex/convex/dashboard.ts +199 -0
  86. package/fixtures/vp-convex/convex/gumroadWebhook.ts +815 -0
  87. package/fixtures/vp-convex/convex/issues.ts +544 -0
  88. package/fixtures/vp-convex/convex/kb.ts +305 -0
  89. package/fixtures/vp-convex/convex/kbMutations.ts +192 -0
  90. package/fixtures/vp-convex/convex/kbShared.ts +56 -0
  91. package/fixtures/vp-convex/convex/licenses.ts +443 -0
  92. package/fixtures/vp-convex/convex/memories.ts +406 -0
  93. package/fixtures/vp-convex/convex/memoriesScoped.ts +220 -0
  94. package/fixtures/vp-convex/convex/messages.ts +971 -0
  95. package/fixtures/vp-convex/convex/migrations.ts +239 -0
  96. package/fixtures/vp-convex/convex/missionTemplates.ts +369 -0
  97. package/fixtures/vp-convex/convex/oauth.ts +1608 -0
  98. package/fixtures/vp-convex/convex/oauthMigrations.ts +96 -0
  99. package/fixtures/vp-convex/convex/okfBundleNode.ts +1031 -0
  100. package/fixtures/vp-convex/convex/recurringTasks.ts +386 -0
  101. package/fixtures/vp-convex/convex/schema.ts +1229 -0
  102. package/fixtures/vp-convex/convex/tasks.ts +2782 -0
  103. package/fixtures/vp-r31-green/.pin +1 -0
  104. package/fixtures/vp-r31-green/convex/migrations.ts +350 -0
  105. package/fixtures/vp-r31-red/.pin +1 -0
  106. package/fixtures/vp-r31-red/convex/migrations.ts +286 -0
  107. package/fixtures/vr-by-tool-day159.csv +89 -0
  108. package/package.json +39 -0
@@ -0,0 +1,815 @@
1
+ "use node";
2
+ /**
3
+ * Gumroad webhook handler for VantagePeers self-host pack auto-delivery.
4
+ *
5
+ * Handles 2 product listings (EN + FR) for the same logical product.
6
+ * On a successful sale:
7
+ * 1. Verifies Gumroad HMAC-SHA256 signature.
8
+ * 2. Whitelists product_id against EN/FR env vars.
9
+ * 3. Idempotency check via gumroadOrderId.
10
+ * 4. Generates license via internal mutation (no master token required here).
11
+ * 5. Sends onboarding email (EN or FR) via Resend.
12
+ * 6. Returns 200 with { ok, licenseId, emailSent }.
13
+ *
14
+ * Environment variables required:
15
+ * GUMROAD_WEBHOOK_SECRET — HMAC secret set in Gumroad product settings
16
+ * GUMROAD_PRODUCT_ID_EN — Gumroad product permalink/id for EN listing
17
+ * GUMROAD_PRODUCT_ID_FR — Gumroad product permalink/id for FR listing
18
+ * RESEND_API_KEY — Resend API key for transactional email
19
+ * RESEND_FROM — Sender address, e.g. "VantagePeers <noreply@vantagepeers.com>"
20
+ * CEDRIC_CALENDAR_URL — (optional) Google Calendar booking URL for Cédric
21
+ */
22
+
23
+ import { createHmac } from "node:crypto";
24
+ import { v } from "convex/values";
25
+ import { internal } from "./_generated/api";
26
+ import { internalAction } from "./_generated/server";
27
+
28
+ // ─────────────────────────────────────────────────────────────────────────────
29
+ // Constants
30
+ // ─────────────────────────────────────────────────────────────────────────────
31
+
32
+ const DOC_URL_EN =
33
+ "https://github.com/vantageos-agency/vantage-peers/blob/main/docs/install-EN.md";
34
+ const DOC_URL_FR =
35
+ "https://github.com/vantageos-agency/vantage-peers/blob/main/docs/install-FR.md";
36
+
37
+ // GitHub repos for session 2. Read from CEDRIC_GITHUB_REPOS_CSV env var in prod
38
+ // (comma-separated list of 5 GitHub URLs). Falls back to placeholder URLs for
39
+ // first ship if the env var is not configured yet.
40
+ function getGithubRepos(): [string, string, string, string, string] {
41
+ const csv = process.env.CEDRIC_GITHUB_REPOS_CSV;
42
+ if (csv) {
43
+ const parts = csv.split(",").map((s) => s.trim());
44
+ if (parts.length >= 5) {
45
+ return [parts[0], parts[1], parts[2], parts[3], parts[4]];
46
+ }
47
+ }
48
+ return [
49
+ "https://github.com/placeholder/repo-1",
50
+ "https://github.com/placeholder/repo-2",
51
+ "https://github.com/placeholder/repo-3",
52
+ "https://github.com/placeholder/repo-4",
53
+ "https://github.com/placeholder/repo-5",
54
+ ];
55
+ }
56
+
57
+ const UNSUBSCRIBE_URL = "https://vantagepeers.com/unsubscribe";
58
+ const PRIVACY_POLICY_URL = "https://vantagepeers.com/privacy";
59
+ const COMPANY_ADDRESS = "VantageOS — 75008 Paris, France";
60
+
61
+ // ─────────────────────────────────────────────────────────────────────────────
62
+ // Email templates (embedded — avoids fs dependency in action bundle)
63
+ // ─────────────────────────────────────────────────────────────────────────────
64
+
65
+ const TEMPLATE_EN_TXT = `Subject: Welcome to VantagePeers, {{CUSTOMER_NAME}}
66
+
67
+ ========================================
68
+ VANTAGEPEERS — SELF-HOST ONBOARDING PACK
69
+ ========================================
70
+
71
+ Welcome, {{CUSTOMER_NAME}}.
72
+
73
+ Thank you for choosing VantagePeers. Your open-core self-host licence is
74
+ now active and everything you need to get started is in this email.
75
+
76
+ We have scheduled 3 onboarding sessions of 30 minutes each:
77
+ - Session 1: Configure your environment
78
+ - Session 2: Review your GitHub repositories together
79
+ - Session 3: Close open questions before you go live
80
+
81
+ Use the calendar link below to lock in times that suit you.
82
+
83
+
84
+ ----------------------------------------
85
+ YOUR LICENSE KEY
86
+ ----------------------------------------
87
+
88
+ {{LICENSE_KEY}}
89
+
90
+ Keep this key secure. It is tied to your account and required for every
91
+ installation.
92
+
93
+
94
+ ----------------------------------------
95
+ INSTALLATION DOCUMENTATION
96
+ ----------------------------------------
97
+
98
+ Start with the English guide:
99
+ {{DOC_URL_EN}}
100
+
101
+ A French version is also available if useful for your team:
102
+ {{DOC_URL_FR}}
103
+
104
+
105
+ ----------------------------------------
106
+ BOOK YOUR 3 ONBOARDING SESSIONS
107
+ ----------------------------------------
108
+
109
+ Sessions are 30 minutes each and can be spread across days or weeks —
110
+ your pace.
111
+
112
+ Book your 3 sessions here:
113
+ {{CALENDAR_URL}}
114
+
115
+ (Link opens Google Calendar. No account required to view available slots.)
116
+
117
+
118
+ ----------------------------------------
119
+ YOUR GITHUB REPOSITORIES (SESSION 2 AGENDA)
120
+ ----------------------------------------
121
+
122
+ In session 2 we will walk through your repositories together.
123
+ We have pre-loaded the following:
124
+
125
+ * {{GITHUB_REPO_1}}
126
+ * {{GITHUB_REPO_2}}
127
+ * {{GITHUB_REPO_3}}
128
+ * {{GITHUB_REPO_4}}
129
+ * {{GITHUB_REPO_5}}
130
+
131
+ We will review these together in session 2. If this list needs updating,
132
+ reply to this email before our first session.
133
+
134
+
135
+ ----------------------------------------
136
+ DIRECT SUPPORT
137
+ ----------------------------------------
138
+
139
+ For any question outside of sessions, reach Laurent Perello directly at:
140
+ lp@perello.consulting
141
+
142
+ Expect a reply within one business day.
143
+
144
+
145
+ ----------------------------------------
146
+
147
+ Pi & the orchestrators team
148
+ VantageOS — VantagePeers
149
+
150
+
151
+ ========================================
152
+ LEGAL FOOTER
153
+ ========================================
154
+
155
+ This is a transactional email sent because you purchased a VantagePeers
156
+ licence. Even so, you may unsubscribe from non-transactional
157
+ communications here: {{UNSUBSCRIBE_URL}}
158
+
159
+ Your data is processed in accordance with our Privacy Policy
160
+ ({{PRIVACY_POLICY_URL}}) and GDPR Article 6(1)(b) (performance of a
161
+ contract). Data controller: VantageOS.
162
+
163
+ {{COMPANY_ADDRESS}}`;
164
+
165
+ const TEMPLATE_FR_TXT = `Objet : Bienvenue chez VantagePeers, {{CUSTOMER_NAME}}
166
+
167
+ ========================================
168
+ VANTAGEPEERS — PACK D'ONBOARDING SELF-HOST
169
+ ========================================
170
+
171
+ Cher {{CUSTOMER_NAME}},
172
+
173
+ Nous avons le plaisir de vous accueillir chez VantagePeers. Votre licence
174
+ open-core self-host est desormais active et vous trouverez ci-dessous tout
175
+ ce dont vous avez besoin pour demarrer.
176
+
177
+ Nous avons prevu 3 sessions d'onboarding de 30 minutes chacune :
178
+ - Session 1 : Configuration de votre environnement
179
+ - Session 2 : Revue de vos depots GitHub ensemble
180
+ - Session 3 : Reponse a vos dernieres questions avant la mise en production
181
+
182
+ Utilisez le lien de calendrier ci-dessous pour choisir les creneaux qui
183
+ vous conviennent.
184
+
185
+
186
+ ----------------------------------------
187
+ VOTRE CLE DE LICENCE
188
+ ----------------------------------------
189
+
190
+ {{LICENSE_KEY}}
191
+
192
+ Conservez cette cle en lieu sur. Elle est liee a votre compte et requise
193
+ pour chaque installation.
194
+
195
+
196
+ ----------------------------------------
197
+ DOCUMENTATION D'INSTALLATION
198
+ ----------------------------------------
199
+
200
+ Vous trouverez ci-dessous le guide en francais :
201
+ {{DOC_URL_FR}}
202
+
203
+ Une version en anglais est egalement disponible pour votre equipe si besoin :
204
+ {{DOC_URL_EN}}
205
+
206
+
207
+ ----------------------------------------
208
+ RESERVER VOS 3 SESSIONS D'ONBOARDING
209
+ ----------------------------------------
210
+
211
+ Les sessions sont de 30 minutes chacune et peuvent etre reparties sur
212
+ plusieurs jours ou semaines, selon vos disponibilites.
213
+
214
+ Reserver vos 3 sessions :
215
+ {{CALENDAR_URL}}
216
+
217
+ (Le lien ouvre Google Calendar. Aucun compte requis pour consulter les
218
+ creneaux disponibles.)
219
+
220
+
221
+ ----------------------------------------
222
+ VOS DEPOTS GITHUB (PROGRAMME DE LA SESSION 2)
223
+ ----------------------------------------
224
+
225
+ Lors de la session 2, nous parcourrons ensemble vos depots.
226
+ Nous avons pre-enregistre les suivants :
227
+
228
+ * {{GITHUB_REPO_1}}
229
+ * {{GITHUB_REPO_2}}
230
+ * {{GITHUB_REPO_3}}
231
+ * {{GITHUB_REPO_4}}
232
+ * {{GITHUB_REPO_5}}
233
+
234
+ Nous passerons en revue ces depots lors de la session 2. Si cette liste
235
+ necessite des ajustements, repondez a cet email avant notre premiere
236
+ session.
237
+
238
+
239
+ ----------------------------------------
240
+ CONTACT DIRECT
241
+ ----------------------------------------
242
+
243
+ Pour toute question en dehors des sessions, vous pouvez contacter Laurent
244
+ Perello directement a l'adresse :
245
+ lp@perello.consulting
246
+
247
+ Comptez une reponse sous un jour ouvre.
248
+
249
+
250
+ ----------------------------------------
251
+
252
+ Pi & l'equipe des orchestrateurs
253
+ VantageOS — VantagePeers
254
+
255
+
256
+ ========================================
257
+ MENTIONS LEGALES
258
+ ========================================
259
+
260
+ Cet email transactionnel vous est adresse en raison de votre achat d'une
261
+ licence VantagePeers. Vous pouvez neanmoins vous desabonner des
262
+ communications non transactionnelles ici : {{UNSUBSCRIBE_URL}}
263
+
264
+ Vos donnees sont traitees conformement a notre Politique de confidentialite
265
+ ({{PRIVACY_POLICY_URL}}) et a l'article 6(1)(b) du RGPD (execution d'un
266
+ contrat). Responsable du traitement : VantageOS.
267
+
268
+ {{COMPANY_ADDRESS}}`;
269
+
270
+ // HTML templates — imported as string literals from the W4 design.
271
+ const TEMPLATE_EN_HTML = `<!DOCTYPE html>
272
+ <html lang="en">
273
+ <head>
274
+ <meta charset="UTF-8" />
275
+ <meta name="viewport" content="width=device-width, initial-scale=1.0" />
276
+ <meta http-equiv="X-UA-Compatible" content="IE=edge" />
277
+ <title>Welcome to VantagePeers, {{CUSTOMER_NAME}}</title>
278
+ </head>
279
+ <body style="margin:0;padding:0;background-color:#f4f4f4;font-family:Arial,Helvetica,sans-serif;">
280
+ <div style="display:none;max-height:0;overflow:hidden;color:#f4f4f4;font-size:1px;">
281
+ Your VantagePeers onboarding pack is ready &mdash; license key, install docs, and 3 sessions to get you running.
282
+ </div>
283
+ <table role="presentation" width="100%" cellpadding="0" cellspacing="0" border="0" style="background-color:#f4f4f4;">
284
+ <tr>
285
+ <td align="center" style="padding:24px 12px;">
286
+ <table role="presentation" width="600" cellpadding="0" cellspacing="0" border="0"
287
+ style="max-width:600px;width:100%;background-color:#ffffff;border-radius:6px;overflow:hidden;box-shadow:0 1px 4px rgba(0,0,0,0.08);">
288
+ <tr>
289
+ <td style="background-color:#0f172a;padding:28px 36px;">
290
+ <p style="margin:0;font-size:20px;font-weight:700;color:#ffffff;letter-spacing:-0.3px;">VantagePeers</p>
291
+ <p style="margin:4px 0 0;font-size:13px;color:#94a3b8;letter-spacing:0.4px;text-transform:uppercase;">Self-Host Onboarding Pack</p>
292
+ </td>
293
+ </tr>
294
+ <tr>
295
+ <td style="padding:36px 36px 24px;">
296
+ <p style="margin:0 0 20px;font-size:17px;font-weight:600;color:#0f172a;">Welcome, {{CUSTOMER_NAME}}.</p>
297
+ <p style="margin:0 0 16px;font-size:15px;line-height:1.7;color:#334155;">
298
+ Thank you for choosing VantagePeers. Your open-core self-host licence is now active.
299
+ </p>
300
+ <hr style="border:none;border-top:1px solid #e2e8f0;margin:0 0 28px;" />
301
+ <p style="margin:0 0 10px;font-size:13px;font-weight:700;color:#0f172a;text-transform:uppercase;letter-spacing:0.6px;">Your License Key</p>
302
+ <table role="presentation" width="100%" cellpadding="0" cellspacing="0" border="0">
303
+ <tr>
304
+ <td style="background-color:#f1f5f9;border:1px solid #cbd5e1;border-radius:4px;padding:16px 20px;">
305
+ <p style="margin:0;font-family:'Courier New',Courier,monospace;font-size:15px;font-weight:700;color:#0f172a;letter-spacing:0.05em;word-break:break-all;">{{LICENSE_KEY}}</p>
306
+ </td>
307
+ </tr>
308
+ </table>
309
+ <p style="margin:8px 0 28px;font-size:12px;color:#64748b;">Keep this key secure. It is tied to your account and required for every installation.</p>
310
+ <hr style="border:none;border-top:1px solid #e2e8f0;margin:0 0 28px;" />
311
+ <p style="margin:0 0 10px;font-size:13px;font-weight:700;color:#0f172a;text-transform:uppercase;letter-spacing:0.6px;">Installation Documentation</p>
312
+ <p style="margin:0 0 8px;font-size:15px;"><a href="{{DOC_URL_EN}}" style="color:#2563eb;text-decoration:underline;font-weight:600;">VantagePeers Install Guide (EN) &rarr;</a></p>
313
+ <p style="margin:0 0 28px;font-size:15px;"><a href="{{DOC_URL_FR}}" style="color:#2563eb;text-decoration:underline;">Guide d'installation VantagePeers (FR) &rarr;</a></p>
314
+ <hr style="border:none;border-top:1px solid #e2e8f0;margin:0 0 28px;" />
315
+ <p style="margin:0 0 10px;font-size:13px;font-weight:700;color:#0f172a;text-transform:uppercase;letter-spacing:0.6px;">Book Your 3 Onboarding Sessions</p>
316
+ <table role="presentation" cellpadding="0" cellspacing="0" border="0">
317
+ <tr>
318
+ <td style="background-color:#0f172a;border-radius:4px;padding:12px 24px;">
319
+ <a href="{{CALENDAR_URL}}" style="font-size:15px;font-weight:600;color:#ffffff;text-decoration:none;display:inline-block;">Book your 3 sessions &rarr;</a>
320
+ </td>
321
+ </tr>
322
+ </table>
323
+ <p style="margin:8px 0 28px;font-size:12px;color:#64748b;">Link opens Google Calendar.</p>
324
+ <hr style="border:none;border-top:1px solid #e2e8f0;margin:0 0 28px;" />
325
+ <p style="margin:0 0 10px;font-size:13px;font-weight:700;color:#0f172a;text-transform:uppercase;letter-spacing:0.6px;">Your GitHub Repositories (Session 2 Agenda)</p>
326
+ <table role="presentation" width="100%" cellpadding="0" cellspacing="0" border="0" style="background-color:#f8fafc;border:1px solid #e2e8f0;border-radius:4px;">
327
+ <tr><td style="padding:16px 20px;">
328
+ <p style="margin:4px 0;font-size:14px;color:#334155;">&bull;&nbsp;<a href="{{GITHUB_REPO_1}}" style="color:#2563eb;">{{GITHUB_REPO_1}}</a></p>
329
+ <p style="margin:4px 0;font-size:14px;color:#334155;">&bull;&nbsp;<a href="{{GITHUB_REPO_2}}" style="color:#2563eb;">{{GITHUB_REPO_2}}</a></p>
330
+ <p style="margin:4px 0;font-size:14px;color:#334155;">&bull;&nbsp;<a href="{{GITHUB_REPO_3}}" style="color:#2563eb;">{{GITHUB_REPO_3}}</a></p>
331
+ <p style="margin:4px 0;font-size:14px;color:#334155;">&bull;&nbsp;<a href="{{GITHUB_REPO_4}}" style="color:#2563eb;">{{GITHUB_REPO_4}}</a></p>
332
+ <p style="margin:4px 0;font-size:14px;color:#334155;">&bull;&nbsp;<a href="{{GITHUB_REPO_5}}" style="color:#2563eb;">{{GITHUB_REPO_5}}</a></p>
333
+ </td></tr>
334
+ </table>
335
+ <p style="margin:8px 0 28px;font-size:12px;color:#64748b;">Reply to this email if this list needs updating before session 2.</p>
336
+ <hr style="border:none;border-top:1px solid #e2e8f0;margin:0 0 28px;" />
337
+ <p style="margin:0 0 28px;font-size:15px;line-height:1.7;color:#334155;">
338
+ For any question outside of sessions, reach Laurent Perello directly at
339
+ <a href="mailto:lp@perello.consulting" style="color:#2563eb;font-weight:600;">lp@perello.consulting</a>.
340
+ </p>
341
+ <p style="margin:0 0 4px;font-size:15px;color:#0f172a;font-weight:600;">Pi &amp; the orchestrators team</p>
342
+ <p style="margin:0 0 28px;font-size:14px;color:#64748b;">VantageOS &mdash; VantagePeers</p>
343
+ </td>
344
+ </tr>
345
+ <tr>
346
+ <td style="background-color:#f8fafc;border-top:1px solid #e2e8f0;padding:20px 36px;">
347
+ <p style="margin:0 0 6px;font-size:11px;color:#94a3b8;line-height:1.6;">
348
+ This is a transactional email. You may <a href="{{UNSUBSCRIBE_URL}}" style="color:#64748b;">unsubscribe from non-transactional communications here</a>.
349
+ </p>
350
+ <p style="margin:0 0 6px;font-size:11px;color:#94a3b8;line-height:1.6;">
351
+ Your data is processed per our <a href="{{PRIVACY_POLICY_URL}}" style="color:#64748b;">Privacy Policy</a> and GDPR Art. 6(1)(b). Data controller: VantageOS.
352
+ </p>
353
+ <p style="margin:0;font-size:11px;color:#94a3b8;line-height:1.6;">{{COMPANY_ADDRESS}}</p>
354
+ </td>
355
+ </tr>
356
+ </table>
357
+ </td>
358
+ </tr>
359
+ </table>
360
+ </body>
361
+ </html>`;
362
+
363
+ const TEMPLATE_FR_HTML = `<!DOCTYPE html>
364
+ <html lang="fr">
365
+ <head>
366
+ <meta charset="UTF-8" />
367
+ <meta name="viewport" content="width=device-width, initial-scale=1.0" />
368
+ <meta http-equiv="X-UA-Compatible" content="IE=edge" />
369
+ <title>Bienvenue chez VantagePeers, {{CUSTOMER_NAME}}</title>
370
+ </head>
371
+ <body style="margin:0;padding:0;background-color:#f4f4f4;font-family:Arial,Helvetica,sans-serif;">
372
+ <div style="display:none;max-height:0;overflow:hidden;color:#f4f4f4;font-size:1px;">
373
+ Votre pack d'onboarding VantagePeers est pret &mdash; cle de licence, documentation et 3 sessions pour demarrer.
374
+ </div>
375
+ <table role="presentation" width="100%" cellpadding="0" cellspacing="0" border="0" style="background-color:#f4f4f4;">
376
+ <tr>
377
+ <td align="center" style="padding:24px 12px;">
378
+ <table role="presentation" width="600" cellpadding="0" cellspacing="0" border="0"
379
+ style="max-width:600px;width:100%;background-color:#ffffff;border-radius:6px;overflow:hidden;box-shadow:0 1px 4px rgba(0,0,0,0.08);">
380
+ <tr>
381
+ <td style="background-color:#0f172a;padding:28px 36px;">
382
+ <p style="margin:0;font-size:20px;font-weight:700;color:#ffffff;letter-spacing:-0.3px;">VantagePeers</p>
383
+ <p style="margin:4px 0 0;font-size:13px;color:#94a3b8;letter-spacing:0.4px;text-transform:uppercase;">Pack d'onboarding Self-Host</p>
384
+ </td>
385
+ </tr>
386
+ <tr>
387
+ <td style="padding:36px 36px 24px;">
388
+ <p style="margin:0 0 20px;font-size:17px;font-weight:600;color:#0f172a;">Cher {{CUSTOMER_NAME}},</p>
389
+ <p style="margin:0 0 16px;font-size:15px;line-height:1.7;color:#334155;">
390
+ Nous avons le plaisir de vous accueillir chez VantagePeers. Votre licence open-core self-host est desormais active.
391
+ </p>
392
+ <hr style="border:none;border-top:1px solid #e2e8f0;margin:0 0 28px;" />
393
+ <p style="margin:0 0 10px;font-size:13px;font-weight:700;color:#0f172a;text-transform:uppercase;letter-spacing:0.6px;">Votre cle de licence</p>
394
+ <table role="presentation" width="100%" cellpadding="0" cellspacing="0" border="0">
395
+ <tr>
396
+ <td style="background-color:#f1f5f9;border:1px solid #cbd5e1;border-radius:4px;padding:16px 20px;">
397
+ <p style="margin:0;font-family:'Courier New',Courier,monospace;font-size:15px;font-weight:700;color:#0f172a;letter-spacing:0.05em;word-break:break-all;">{{LICENSE_KEY}}</p>
398
+ </td>
399
+ </tr>
400
+ </table>
401
+ <p style="margin:8px 0 28px;font-size:12px;color:#64748b;">Conservez cette cle en lieu sur. Elle est liee a votre compte et requise pour chaque installation.</p>
402
+ <hr style="border:none;border-top:1px solid #e2e8f0;margin:0 0 28px;" />
403
+ <p style="margin:0 0 10px;font-size:13px;font-weight:700;color:#0f172a;text-transform:uppercase;letter-spacing:0.6px;">Documentation d'installation</p>
404
+ <p style="margin:0 0 8px;font-size:15px;"><a href="{{DOC_URL_FR}}" style="color:#2563eb;text-decoration:underline;font-weight:600;">Guide d'installation VantagePeers (FR) &rarr;</a></p>
405
+ <p style="margin:0 0 28px;font-size:15px;"><a href="{{DOC_URL_EN}}" style="color:#2563eb;text-decoration:underline;">VantagePeers Install Guide (EN) &rarr;</a></p>
406
+ <hr style="border:none;border-top:1px solid #e2e8f0;margin:0 0 28px;" />
407
+ <p style="margin:0 0 10px;font-size:13px;font-weight:700;color:#0f172a;text-transform:uppercase;letter-spacing:0.6px;">Reserver vos 3 sessions d'onboarding</p>
408
+ <table role="presentation" cellpadding="0" cellspacing="0" border="0">
409
+ <tr>
410
+ <td style="background-color:#0f172a;border-radius:4px;padding:12px 24px;">
411
+ <a href="{{CALENDAR_URL}}" style="font-size:15px;font-weight:600;color:#ffffff;text-decoration:none;display:inline-block;">Reserver vos 3 sessions &rarr;</a>
412
+ </td>
413
+ </tr>
414
+ </table>
415
+ <p style="margin:8px 0 28px;font-size:12px;color:#64748b;">Le lien ouvre Google Calendar.</p>
416
+ <hr style="border:none;border-top:1px solid #e2e8f0;margin:0 0 28px;" />
417
+ <p style="margin:0 0 10px;font-size:13px;font-weight:700;color:#0f172a;text-transform:uppercase;letter-spacing:0.6px;">Vos depots GitHub (programme de la session 2)</p>
418
+ <table role="presentation" width="100%" cellpadding="0" cellspacing="0" border="0" style="background-color:#f8fafc;border:1px solid #e2e8f0;border-radius:4px;">
419
+ <tr><td style="padding:16px 20px;">
420
+ <p style="margin:4px 0;font-size:14px;color:#334155;">&bull;&nbsp;<a href="{{GITHUB_REPO_1}}" style="color:#2563eb;">{{GITHUB_REPO_1}}</a></p>
421
+ <p style="margin:4px 0;font-size:14px;color:#334155;">&bull;&nbsp;<a href="{{GITHUB_REPO_2}}" style="color:#2563eb;">{{GITHUB_REPO_2}}</a></p>
422
+ <p style="margin:4px 0;font-size:14px;color:#334155;">&bull;&nbsp;<a href="{{GITHUB_REPO_3}}" style="color:#2563eb;">{{GITHUB_REPO_3}}</a></p>
423
+ <p style="margin:4px 0;font-size:14px;color:#334155;">&bull;&nbsp;<a href="{{GITHUB_REPO_4}}" style="color:#2563eb;">{{GITHUB_REPO_4}}</a></p>
424
+ <p style="margin:4px 0;font-size:14px;color:#334155;">&bull;&nbsp;<a href="{{GITHUB_REPO_5}}" style="color:#2563eb;">{{GITHUB_REPO_5}}</a></p>
425
+ </td></tr>
426
+ </table>
427
+ <p style="margin:8px 0 28px;font-size:12px;color:#64748b;">Repondez a cet email si cette liste necessite des ajustements avant la session 2.</p>
428
+ <hr style="border:none;border-top:1px solid #e2e8f0;margin:0 0 28px;" />
429
+ <p style="margin:0 0 28px;font-size:15px;line-height:1.7;color:#334155;">
430
+ Pour toute question, contactez Laurent Perello a
431
+ <a href="mailto:lp@perello.consulting" style="color:#2563eb;font-weight:600;">lp@perello.consulting</a>.
432
+ </p>
433
+ <p style="margin:0 0 4px;font-size:15px;color:#0f172a;font-weight:600;">Pi &amp; l'equipe des orchestrateurs</p>
434
+ <p style="margin:0 0 28px;font-size:14px;color:#64748b;">VantageOS &mdash; VantagePeers</p>
435
+ </td>
436
+ </tr>
437
+ <tr>
438
+ <td style="background-color:#f8fafc;border-top:1px solid #e2e8f0;padding:20px 36px;">
439
+ <p style="margin:0 0 6px;font-size:11px;color:#94a3b8;line-height:1.6;">
440
+ Email transactionnel. <a href="{{UNSUBSCRIBE_URL}}" style="color:#64748b;">Se desabonner des communications non transactionnelles</a>.
441
+ </p>
442
+ <p style="margin:0 0 6px;font-size:11px;color:#94a3b8;line-height:1.6;">
443
+ Donnees traitees per notre <a href="{{PRIVACY_POLICY_URL}}" style="color:#64748b;">Politique de confidentialite</a> et RGPD Art. 6(1)(b). Responsable : VantageOS.
444
+ </p>
445
+ <p style="margin:0;font-size:11px;color:#94a3b8;line-height:1.6;">{{COMPANY_ADDRESS}}</p>
446
+ </td>
447
+ </tr>
448
+ </table>
449
+ </td>
450
+ </tr>
451
+ </table>
452
+ </body>
453
+ </html>`;
454
+
455
+ // ─────────────────────────────────────────────────────────────────────────────
456
+ // Template substitution helpers
457
+ // ─────────────────────────────────────────────────────────────────────────────
458
+
459
+ /**
460
+ * Escape a string for safe interpolation into an HTML context.
461
+ * Applied to all attacker-controlled values (e.g. customerName from Gumroad)
462
+ * before substitution into HTML email templates. Not applied to plain-text
463
+ * templates (no parsing risk there).
464
+ */
465
+ function escapeHtml(s: string): string {
466
+ return s
467
+ .replace(/&/g, "&amp;")
468
+ .replace(/</g, "&lt;")
469
+ .replace(/>/g, "&gt;")
470
+ .replace(/"/g, "&quot;")
471
+ .replace(/'/g, "&#39;");
472
+ }
473
+
474
+ /**
475
+ * Fill {{KEY}} placeholders in a template.
476
+ * When isHtml=true, all values are HTML-escaped before substitution to prevent
477
+ * injection from attacker-controlled fields (e.g. Gumroad full_name).
478
+ */
479
+ function fillTemplate(
480
+ template: string,
481
+ vars: Record<string, string>,
482
+ isHtml: boolean,
483
+ ): string {
484
+ let result = template;
485
+ for (const [key, value] of Object.entries(vars)) {
486
+ const safe = isHtml ? escapeHtml(value) : value;
487
+ result = result.split(`{{${key}}}`).join(safe);
488
+ }
489
+ return result;
490
+ }
491
+
492
+ // ─────────────────────────────────────────────────────────────────────────────
493
+ // Gumroad signature verification
494
+ // Gumroad signs with HMAC-SHA256 and sends the hex digest in the
495
+ // "X-Gumroad-Signature" header.
496
+ // ─────────────────────────────────────────────────────────────────────────────
497
+
498
+ function verifyGumroadSignature(
499
+ body: string,
500
+ signature: string,
501
+ secret: string,
502
+ ): boolean {
503
+ const expected = createHmac("sha256", secret).update(body).digest("hex");
504
+ // Constant-time comparison using Buffer.from to avoid timing attacks
505
+ const sigBuf = Buffer.from(signature);
506
+ const expBuf = Buffer.from(expected);
507
+ if (sigBuf.length !== expBuf.length) return false;
508
+ let diff = 0;
509
+ for (let i = 0; i < sigBuf.length; i++) {
510
+ diff |= sigBuf[i] ^ expBuf[i];
511
+ }
512
+ return diff === 0;
513
+ }
514
+
515
+ // ─────────────────────────────────────────────────────────────────────────────
516
+ // Resend email send helper
517
+ // ─────────────────────────────────────────────────────────────────────────────
518
+
519
+ async function sendViaResend(params: {
520
+ to: string;
521
+ subject: string;
522
+ html: string;
523
+ text: string;
524
+ }): Promise<{ ok: boolean; error?: string }> {
525
+ const apiKey = process.env.RESEND_API_KEY;
526
+ const from =
527
+ process.env.RESEND_FROM ?? "VantagePeers <noreply@vantagepeers.com>";
528
+
529
+ if (!apiKey) {
530
+ return { ok: false, error: "RESEND_API_KEY not configured" };
531
+ }
532
+
533
+ try {
534
+ const res = await fetch("https://api.resend.com/emails", {
535
+ method: "POST",
536
+ headers: {
537
+ Authorization: `Bearer ${apiKey}`,
538
+ "Content-Type": "application/json",
539
+ },
540
+ body: JSON.stringify({
541
+ from,
542
+ to: [params.to],
543
+ subject: params.subject,
544
+ html: params.html,
545
+ text: params.text,
546
+ }),
547
+ });
548
+
549
+ if (!res.ok) {
550
+ const body = await res.text();
551
+ return { ok: false, error: `Resend HTTP ${res.status}: ${body}` };
552
+ }
553
+
554
+ return { ok: true };
555
+ } catch (err) {
556
+ return {
557
+ ok: false,
558
+ error: err instanceof Error ? err.message : String(err),
559
+ };
560
+ }
561
+ }
562
+
563
+ // ─────────────────────────────────────────────────────────────────────────────
564
+ // handleGumroadWebhook — internal action called from http.ts
565
+ // ─────────────────────────────────────────────────────────────────────────────
566
+
567
+ export const handleGumroadWebhook = internalAction({
568
+ args: {
569
+ body: v.string(),
570
+ signature: v.union(v.string(), v.null()),
571
+ },
572
+ returns: v.object({
573
+ status: v.number(),
574
+ payload: v.string(),
575
+ }),
576
+ handler: async (ctx, args): Promise<{ status: number; payload: string }> => {
577
+ // ── 1. Signature verification ────────────────────────────────────────────
578
+ const webhookSecret = process.env.GUMROAD_WEBHOOK_SECRET;
579
+ if (!webhookSecret) {
580
+ console.error("[gumroad-webhook] GUMROAD_WEBHOOK_SECRET not set");
581
+ return {
582
+ status: 500,
583
+ payload: JSON.stringify({ error: "Server misconfiguration" }),
584
+ };
585
+ }
586
+
587
+ if (!args.signature) {
588
+ console.error("[gumroad-webhook] Missing X-Gumroad-Signature header");
589
+ return {
590
+ status: 401,
591
+ payload: JSON.stringify({ error: "Missing signature" }),
592
+ };
593
+ }
594
+
595
+ const signatureValid = verifyGumroadSignature(
596
+ args.body,
597
+ args.signature,
598
+ webhookSecret,
599
+ );
600
+ if (!signatureValid) {
601
+ console.error("[gumroad-webhook] Invalid HMAC signature — rejecting");
602
+ return {
603
+ status: 401,
604
+ payload: JSON.stringify({ error: "Invalid signature" }),
605
+ };
606
+ }
607
+
608
+ // ── 2. Parse Gumroad payload (form-encoded) ──────────────────────────────
609
+ // Gumroad sends application/x-www-form-urlencoded
610
+ let params: URLSearchParams;
611
+ try {
612
+ params = new URLSearchParams(args.body);
613
+ } catch {
614
+ return {
615
+ status: 400,
616
+ payload: JSON.stringify({ error: "Invalid form body" }),
617
+ };
618
+ }
619
+
620
+ const incomingProductId =
621
+ params.get("product_id") ?? params.get("product_permalink") ?? "";
622
+ const gumroadOrderId =
623
+ params.get("sale_id") ?? params.get("order_id") ?? "";
624
+ const customerEmail = params.get("email") ?? "";
625
+ const customerName =
626
+ params.get("full_name") ?? params.get("buyer_name") ?? undefined;
627
+
628
+ if (!customerEmail) {
629
+ return {
630
+ status: 400,
631
+ payload: JSON.stringify({ error: "Missing email in payload" }),
632
+ };
633
+ }
634
+
635
+ // ── 3. Whitelist product_id → derive locale ──────────────────────────────
636
+ const whitelistedEn = process.env.GUMROAD_PRODUCT_ID_EN;
637
+ const whitelistedFr = process.env.GUMROAD_PRODUCT_ID_FR;
638
+
639
+ if (!whitelistedEn || !whitelistedFr) {
640
+ console.error(
641
+ "[gumroad-webhook] GUMROAD_PRODUCT_ID_EN or GUMROAD_PRODUCT_ID_FR not configured — rejecting",
642
+ );
643
+ return {
644
+ status: 500,
645
+ payload: JSON.stringify({ error: "Server misconfiguration" }),
646
+ };
647
+ }
648
+
649
+ let purchaseLocale: "en" | "fr";
650
+ if (incomingProductId === whitelistedEn) {
651
+ purchaseLocale = "en";
652
+ } else if (incomingProductId === whitelistedFr) {
653
+ purchaseLocale = "fr";
654
+ } else {
655
+ console.error(
656
+ `[gumroad-webhook] Non-whitelisted product_id: "${incomingProductId}" — ALERT: possible misconfiguration or fraud`,
657
+ );
658
+ return {
659
+ status: 400,
660
+ payload: JSON.stringify({ error: "Product not authorized" }),
661
+ };
662
+ }
663
+
664
+ // ── 4 + 5. Atomic findOrCreate (idempotency + trial upgrade + new insert) ──
665
+ // Single internalMutation to eliminate the race condition between a separate
666
+ // query and insert (M2 fix). Handles:
667
+ // a) Duplicate gumroadOrderId → return existing (no new row)
668
+ // b) Trial license for email → upgrade to active (keep keyHash)
669
+ // c) Net-new → generate key + insert
670
+ if (!gumroadOrderId) {
671
+ return {
672
+ status: 400,
673
+ payload: JSON.stringify({ error: "Missing sale_id in payload" }),
674
+ };
675
+ }
676
+
677
+ type FindOrCreateResult = {
678
+ licenseKey: string | null;
679
+ licenseId: string;
680
+ expiresAt: number;
681
+ isExisting: boolean;
682
+ isUpgraded: boolean;
683
+ customerEmail: string;
684
+ emailSent?: boolean;
685
+ };
686
+
687
+ let findOrCreateResult: FindOrCreateResult;
688
+ try {
689
+ findOrCreateResult = (await ctx.runMutation(
690
+ internal.licenses.findOrCreateForGumroad,
691
+ {
692
+ customerEmail,
693
+ customerName,
694
+ productCode: "vantage-peers-self-host",
695
+ tier: "open-core-99-eur-yr",
696
+ purchaseLocale,
697
+ gumroadOrderId,
698
+ expiresInDays: 365,
699
+ },
700
+ )) as FindOrCreateResult;
701
+ } catch (err) {
702
+ console.error("[gumroad-webhook] findOrCreateForGumroad failed:", err);
703
+ return {
704
+ status: 500,
705
+ payload: JSON.stringify({
706
+ error: `License generation failed: ${err instanceof Error ? err.message : String(err)}`,
707
+ }),
708
+ };
709
+ }
710
+
711
+ const licenseId = findOrCreateResult.licenseId;
712
+ const licenseKey = findOrCreateResult.licenseKey;
713
+
714
+ // If this was a duplicate order (same gumroadOrderId seen before), return early.
715
+ if (findOrCreateResult.isExisting && !findOrCreateResult.isUpgraded) {
716
+ // Mask PII: log only first 3 chars + *** + domain
717
+ const emailParts = customerEmail.split("@");
718
+ const maskedEmail = `${emailParts[0].slice(0, 3)}***@${emailParts[1] ?? ""}`;
719
+ console.log(
720
+ `[gumroad-webhook] Duplicate sale_id "${gumroadOrderId}" for ${maskedEmail} — returning existing license ${licenseId}`,
721
+ );
722
+ return {
723
+ status: 200,
724
+ payload: JSON.stringify({
725
+ ok: true,
726
+ licenseId,
727
+ emailSent: findOrCreateResult.emailSent ?? false,
728
+ duplicate: true,
729
+ }),
730
+ };
731
+ }
732
+
733
+ // ── 6. Build and send onboarding email ────────────────────────────────────
734
+ // CALENDAR_URL: set CEDRIC_CALENDAR_URL env var in prod.
735
+ // Fallback to placeholder if not configured.
736
+ const calendarUrl =
737
+ process.env.CEDRIC_CALENDAR_URL ??
738
+ "https://calendar.app.google/PLACEHOLDER"; // Pi/Laurent: set CEDRIC_CALENDAR_URL in Convex dashboard
739
+
740
+ const displayName = customerName ?? customerEmail;
741
+ const repos = getGithubRepos();
742
+
743
+ // For trial→active upgrades, the customer keeps their existing key.
744
+ // We send the onboarding email regardless, but omit the license key block
745
+ // by using a placeholder note. For brand-new licenses, use the real key.
746
+ const licenseKeyDisplay = licenseKey ?? "(use your existing trial key)";
747
+
748
+ const templateVars: Record<string, string> = {
749
+ CUSTOMER_NAME: displayName,
750
+ LICENSE_KEY: licenseKeyDisplay,
751
+ DOC_URL_EN,
752
+ DOC_URL_FR,
753
+ CALENDAR_URL: calendarUrl,
754
+ GITHUB_REPO_1: repos[0],
755
+ GITHUB_REPO_2: repos[1],
756
+ GITHUB_REPO_3: repos[2],
757
+ GITHUB_REPO_4: repos[3],
758
+ GITHUB_REPO_5: repos[4],
759
+ UNSUBSCRIBE_URL,
760
+ PRIVACY_POLICY_URL,
761
+ COMPANY_ADDRESS,
762
+ };
763
+
764
+ const isEn = purchaseLocale === "en";
765
+ const subjectLine = isEn
766
+ ? `Welcome to VantagePeers, ${displayName}`
767
+ : `Bienvenue chez VantagePeers, ${displayName}`;
768
+
769
+ // HTML template: escape all user-controlled substitution values (M1 fix).
770
+ // Plain-text template: no HTML parsing risk, no escaping needed.
771
+ const emailHtml = fillTemplate(
772
+ isEn ? TEMPLATE_EN_HTML : TEMPLATE_FR_HTML,
773
+ templateVars,
774
+ true, // isHtml — escape attacker-controlled fields
775
+ );
776
+ const emailText = fillTemplate(
777
+ isEn ? TEMPLATE_EN_TXT : TEMPLATE_FR_TXT,
778
+ templateVars,
779
+ false, // plain text — no escaping
780
+ );
781
+
782
+ const emailResult = await sendViaResend({
783
+ to: customerEmail,
784
+ subject: subjectLine,
785
+ html: emailHtml,
786
+ text: emailText,
787
+ });
788
+
789
+ // Flag email delivery on the license row regardless of outcome
790
+ await ctx.runMutation(internal.licenses.flagEmailSent, {
791
+ licenseId: licenseId as string & { __tableName: "licenses" },
792
+ emailSent: emailResult.ok,
793
+ });
794
+
795
+ if (!emailResult.ok) {
796
+ // Email failed → log error but DO NOT return 500 (license was generated).
797
+ // The emailSent=false flag on the license row enables an admin retry later.
798
+ const emailParts = customerEmail.split("@");
799
+ const maskedEmail = `${emailParts[0].slice(0, 3)}***@${emailParts[1] ?? ""}`;
800
+ console.error(
801
+ `[gumroad-webhook] Email send failed for ${maskedEmail}: ${emailResult.error}. License ${licenseId} generated. emailSent=false set for retry.`,
802
+ );
803
+ }
804
+
805
+ return {
806
+ status: 200,
807
+ payload: JSON.stringify({
808
+ ok: true,
809
+ licenseId,
810
+ emailSent: emailResult.ok,
811
+ isUpgraded: findOrCreateResult.isUpgraded,
812
+ }),
813
+ };
814
+ },
815
+ });