-
Notifications
You must be signed in to change notification settings - Fork 77
Validate WhatsApp Graph API config #635
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change | ||||
|---|---|---|---|---|---|---|
|
|
@@ -57,6 +57,8 @@ interface TemplateManifestEntry extends WhatsAppTemplateBody { | |||||
|
|
||||||
| const TEMPLATE_NAME_RE = /^[a-z0-9_]+$/; | ||||||
| const LANGUAGE_RE = /^[a-z]{2,3}(_[A-Z]{2})?$/; | ||||||
| const GRAPH_ID_RE = /^[A-Za-z0-9_-]+$/; | ||||||
| const GRAPH_API_VERSION_RE = /^v\d+(?:\.\d+)?$/; | ||||||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
Suggested change
|
||||||
| const DEFAULT_GRAPH_API_VERSION = 'v25.0'; | ||||||
| const DEFAULT_GRAPH_API_BASE_URL = 'https://graph.facebook.com'; | ||||||
|
|
||||||
|
|
@@ -66,22 +68,48 @@ function requireText(value: string | undefined, field: string): string { | |||||
| return text; | ||||||
| } | ||||||
|
|
||||||
| function optionalText(value: string | undefined, field: string): string | undefined { | ||||||
| return value === undefined ? undefined : requireText(value, field); | ||||||
| } | ||||||
|
|
||||||
| function requireGraphId(value: string | undefined, field: string): string { | ||||||
| const id = requireText(value, field); | ||||||
| if (!GRAPH_ID_RE.test(id)) { | ||||||
| throw new Error(`chat-whatsapp ${field} must be a URL-safe Graph API id`); | ||||||
| } | ||||||
| return id; | ||||||
| } | ||||||
|
|
||||||
| function graphApiVersion(config: Config): string { | ||||||
| const version = config.graphApiVersion?.trim() || DEFAULT_GRAPH_API_VERSION; | ||||||
| return version.startsWith('v') ? version : `v${version}`; | ||||||
| const rawVersion = optionalText(config.graphApiVersion, 'graphApiVersion') ?? DEFAULT_GRAPH_API_VERSION; | ||||||
| const version = rawVersion.startsWith('v') ? rawVersion : `v${rawVersion}`; | ||||||
| if (!GRAPH_API_VERSION_RE.test(version)) { | ||||||
| throw new Error('chat-whatsapp graphApiVersion must look like v25.0'); | ||||||
| } | ||||||
| return version; | ||||||
| } | ||||||
|
|
||||||
| function graphApiBaseUrl(config: Config): string { | ||||||
| return (config.graphApiBaseUrl?.trim() || DEFAULT_GRAPH_API_BASE_URL).replace(/\/+$/, ''); | ||||||
| const baseUrl = optionalText(config.graphApiBaseUrl, 'graphApiBaseUrl') ?? DEFAULT_GRAPH_API_BASE_URL; | ||||||
| let parsed: URL; | ||||||
| try { | ||||||
| parsed = new URL(baseUrl); | ||||||
| } catch { | ||||||
| throw new Error('chat-whatsapp graphApiBaseUrl must be a valid HTTPS URL'); | ||||||
| } | ||||||
| if (parsed.protocol !== 'https:') throw new Error('chat-whatsapp graphApiBaseUrl must use HTTPS'); | ||||||
| return baseUrl.replace(/\/+$/, ''); | ||||||
| } | ||||||
|
|
||||||
| function graphUrl(config: Config, path: string): string { | ||||||
| return `${graphApiBaseUrl(config)}/${graphApiVersion(config)}/${path}`; | ||||||
| } | ||||||
|
|
||||||
| function validateBaseConfig(config: Config): void { | ||||||
| requireText(config.phoneNumberId, 'phoneNumberId'); | ||||||
| requireText(config.wabaId, 'wabaId'); | ||||||
| requireGraphId(config.phoneNumberId, 'phoneNumberId'); | ||||||
| requireGraphId(config.wabaId, 'wabaId'); | ||||||
| graphApiVersion(config); | ||||||
| graphApiBaseUrl(config); | ||||||
| const webhookUrl = requireText(config.webhookUrl, 'webhookUrl'); | ||||||
| let parsed: URL; | ||||||
| try { | ||||||
|
|
@@ -140,8 +168,8 @@ function validateTemplate(template: NonNullable<Config['templates']>[number]): T | |||||
|
|
||||||
| function templateManifest(config: Config, version: string) { | ||||||
| validateBaseConfig(config); | ||||||
| const phoneNumberId = requireText(config.phoneNumberId, 'phoneNumberId'); | ||||||
| const wabaId = requireText(config.wabaId, 'wabaId'); | ||||||
| const phoneNumberId = requireGraphId(config.phoneNumberId, 'phoneNumberId'); | ||||||
| const wabaId = requireGraphId(config.wabaId, 'wabaId'); | ||||||
| const webhookUrl = requireText(config.webhookUrl, 'webhookUrl'); | ||||||
| const templates = (config.templates ?? []).map(validateTemplate); | ||||||
| return { | ||||||
|
|
@@ -234,7 +262,7 @@ export default defineTarget<Config>({ | |||||
|
|
||||||
| if (typeof fetch !== 'function') throw new Error('global fetch is not available for WhatsApp Graph API calls'); | ||||||
|
|
||||||
| const tokenKey = config.tokenKey ?? 'WHATSAPP_BUSINESS_TOKEN'; | ||||||
| const tokenKey = optionalText(config.tokenKey, 'tokenKey') ?? 'WHATSAPP_BUSINESS_TOKEN'; | ||||||
| const token = requireSecret(ctx, tokenKey); | ||||||
| const submittedTemplates = []; | ||||||
| for (const template of manifest.templates) { | ||||||
|
|
@@ -247,13 +275,13 @@ export default defineTarget<Config>({ | |||||
| ctx.log('whatsapp · subscribe app to WABA webhooks'); | ||||||
| subscription = await callGraph(manifest.endpoints.subscribedApps, token, { | ||||||
| override_callback_uri: manifest.webhookUrl, | ||||||
| ...(config.verifyTokenKey ? { verify_token: requireSecret(ctx, config.verifyTokenKey) } : {}), | ||||||
| ...(config.verifyTokenKey ? { verify_token: requireSecret(ctx, requireText(config.verifyTokenKey, 'verifyTokenKey')) } : {}), | ||||||
| }); | ||||||
| } | ||||||
|
|
||||||
| return { | ||||||
| id: `${config.phoneNumberId}@${ctx.version}`, | ||||||
| url: `https://business.facebook.com/wa/manage/phone-numbers/?waba_id=${config.wabaId}`, | ||||||
| id: `${manifest.phoneNumberId}@${ctx.version}`, | ||||||
| url: `https://business.facebook.com/wa/manage/phone-numbers/?waba_id=${manifest.wabaId}`, | ||||||
| meta: { | ||||||
| templates: submittedTemplates, | ||||||
| subscription: subscription ? { success: subscription.success ?? true } : undefined, | ||||||
|
|
||||||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
verifyTokenKeyThis PR changed the
verifyTokenKeypath to wrap it withrequireTextbefore passing torequireSecret, but no test exercises a blankverifyTokenKeyvalue. Since' 'is truthy, theif (config.verifyTokenKey)guard lets it through andrequireTextis the only safeguard — a regression there would be silent. A test mirroring thetokenKeyblank case (with a non-dry-run context andverifyTokenKey: ' ') would pin this behaviour.Note: If this suggestion doesn't match your team's coding style, reply to this and let me know. I'll remember it for next time!