@getvitops/utils 8.2.0-alpha.0 → 8.3.0-alpha.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +399 -23
- package/dist/{adc-D6aw2qgq.mjs → adc-BiW9BzqA.mjs} +2 -3
- package/dist/{adc-D6aw2qgq.mjs.map → adc-BiW9BzqA.mjs.map} +1 -1
- package/dist/{adc-BoEoogyo.d.mts → adc-DvX3-P7a.d.mts} +2 -4
- package/dist/adc-DvX3-P7a.d.mts.map +1 -0
- package/dist/ads/index.d.mts +37 -57
- package/dist/ads/index.d.mts.map +1 -1
- package/dist/ads/index.mjs +21 -10
- package/dist/ads/index.mjs.map +1 -1
- package/dist/audit/index.d.mts +180 -0
- package/dist/audit/index.d.mts.map +1 -0
- package/dist/audit/index.mjs +509 -0
- package/dist/audit/index.mjs.map +1 -0
- package/dist/color/index.d.mts +7 -5
- package/dist/color/index.d.mts.map +1 -1
- package/dist/color/index.mjs +10 -5
- package/dist/color/index.mjs.map +1 -1
- package/dist/content.d.mts +78 -0
- package/dist/content.d.mts.map +1 -0
- package/dist/content.mjs +45 -0
- package/dist/content.mjs.map +1 -0
- package/dist/crm/index.d.mts +5 -87
- package/dist/crm/index.d.mts.map +1 -1
- package/dist/crm/index.mjs +10 -19
- package/dist/crm/index.mjs.map +1 -1
- package/dist/email/index.d.mts +1 -11
- package/dist/email/index.d.mts.map +1 -1
- package/dist/email/index.mjs +6 -10
- package/dist/email/index.mjs.map +1 -1
- package/dist/favicon.d.mts +0 -2
- package/dist/favicon.d.mts.map +1 -1
- package/dist/favicon.mjs +1 -2
- package/dist/git/index.d.mts +0 -2
- package/dist/git/index.d.mts.map +1 -1
- package/dist/git/index.mjs +1 -2
- package/dist/hosting/index.d.mts +0 -8
- package/dist/hosting/index.d.mts.map +1 -1
- package/dist/hosting/index.mjs +4 -7
- package/dist/hosting/index.mjs.map +1 -1
- package/dist/{identity-BoRddTx3.mjs → identity-C4yInICW.mjs} +2 -3
- package/dist/{identity-BoRddTx3.mjs.map → identity-C4yInICW.mjs.map} +1 -1
- package/dist/index.d.mts +2 -88
- package/dist/index.d.mts.map +1 -1
- package/dist/index.mjs +36 -52
- package/dist/index.mjs.map +1 -1
- package/dist/indexing/index.d.mts +3 -42
- package/dist/indexing/index.d.mts.map +1 -1
- package/dist/indexing/index.mjs +8 -13
- package/dist/indexing/index.mjs.map +1 -1
- package/dist/{json-store-30CFCbZZ.mjs → json-store-VJKEaoVX.mjs} +2 -3
- package/dist/{json-store-30CFCbZZ.mjs.map → json-store-VJKEaoVX.mjs.map} +1 -1
- package/dist/media/index.d.mts +0 -12
- package/dist/media/index.d.mts.map +1 -1
- package/dist/media/index.mjs +7 -13
- package/dist/media/index.mjs.map +1 -1
- package/dist/notify/index.d.mts +64 -44
- package/dist/notify/index.d.mts.map +1 -1
- package/dist/notify/index.mjs +138 -11
- package/dist/notify/index.mjs.map +1 -1
- package/dist/onboarding/index.d.mts +26 -6
- package/dist/onboarding/index.d.mts.map +1 -1
- package/dist/onboarding/index.mjs +92 -5
- package/dist/onboarding/index.mjs.map +1 -1
- package/dist/{plan-BLKikoOG.mjs → plan-B9USuc17.mjs} +3 -4
- package/dist/{plan-BLKikoOG.mjs.map → plan-B9USuc17.mjs.map} +1 -1
- package/dist/{plan-Hq8Pb0Uw.d.mts → plan-CH3Wt5ZV.d.mts} +8 -30
- package/dist/plan-CH3Wt5ZV.d.mts.map +1 -0
- package/dist/plan-CddqsmJJ.d.mts +82 -0
- package/dist/plan-CddqsmJJ.d.mts.map +1 -0
- package/dist/plan-DpHv6NO8.mjs +93 -0
- package/dist/plan-DpHv6NO8.mjs.map +1 -0
- package/dist/{plan-format-A18SvJ7C.mjs → plan-format-CIz6Feg2.mjs} +2 -3
- package/dist/{plan-format-A18SvJ7C.mjs.map → plan-format-CIz6Feg2.mjs.map} +1 -1
- package/dist/{platform-0nlOIy8h.mjs → platform-Cb_RtsF1.mjs} +3 -5
- package/dist/{platform-0nlOIy8h.mjs.map → platform-Cb_RtsF1.mjs.map} +1 -1
- package/dist/reviews/index.d.mts +4 -120
- package/dist/reviews/index.d.mts.map +1 -1
- package/dist/reviews/index.mjs +6 -99
- package/dist/reviews/index.mjs.map +1 -1
- package/dist/reviews/select.d.mts +2 -0
- package/dist/reviews/select.mjs +3 -0
- package/dist/schema/index.d.mts +0 -12
- package/dist/schema/index.d.mts.map +1 -1
- package/dist/schema/index.mjs +6 -12
- package/dist/schema/index.mjs.map +1 -1
- package/dist/security/index.d.mts +0 -2
- package/dist/security/index.d.mts.map +1 -1
- package/dist/security/index.mjs +1 -2
- package/dist/{token-DmmoQ7um.d.mts → token-CHR1O4w5.d.mts} +3 -3
- package/dist/token-CHR1O4w5.d.mts.map +1 -0
- package/dist/{token-DkKC5m23.mjs → token-CJSl9eIS.mjs} +33 -14
- package/dist/token-CJSl9eIS.mjs.map +1 -0
- package/dist/tracking/index.d.mts +1 -9
- package/dist/tracking/index.d.mts.map +1 -1
- package/dist/tracking/index.mjs +3 -4
- package/dist/turnstile/index.d.mts +105 -0
- package/dist/turnstile/index.d.mts.map +1 -0
- package/dist/turnstile/index.mjs +281 -0
- package/dist/turnstile/index.mjs.map +1 -0
- package/dist/{types-7ELci8RQ.d.mts → types-oac2P5or.d.mts} +3 -23
- package/dist/types-oac2P5or.d.mts.map +1 -0
- package/dist/url/index.d.mts +18 -0
- package/dist/url/index.d.mts.map +1 -0
- package/dist/url/index.mjs +34 -0
- package/dist/url/index.mjs.map +1 -0
- package/package.json +43 -2
- package/dist/adc-BoEoogyo.d.mts.map +0 -1
- package/dist/cloudflare-BiK61Oq4.d.mts +0 -27
- package/dist/cloudflare-BiK61Oq4.d.mts.map +0 -1
- package/dist/cloudflare-CD8vwrgj.mjs +0 -92
- package/dist/cloudflare-CD8vwrgj.mjs.map +0 -1
- package/dist/plan-Hq8Pb0Uw.d.mts.map +0 -1
- package/dist/token-DkKC5m23.mjs.map +0 -1
- package/dist/token-DmmoQ7um.d.mts.map +0 -1
- package/dist/types-7ELci8RQ.d.mts.map +0 -1
package/dist/crm/index.mjs.map
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"index.mjs","names":["EXPIRY_MARGIN_MS","cache"],"sources":["../../src/crm/map.ts","../../src/crm/zoho-token.ts","../../src/crm/bigin.ts","../../src/crm/hubspot.ts","../../src/crm/jobber-token.ts","../../src/crm/jobber.ts","../../src/crm/plan.ts","../../src/crm/types.ts","../../src/crm/index.ts"],"sourcesContent":["/**\n * Turning one submission into a provider-neutral `CrmRecord`.\n *\n * Pure, and separate from both executors for the reason `notify/render.ts` is\n * separate from `email.ts`: shaping the data is where the two subtle rules\n * that must not regress live, and pinning them here means a test can assert\n * them without a network.\n *\n * `fieldMap`/`trackingFields` are drop-if-unmapped, not guess-if-unmapped — an\n * unmapped field silently disappearing from the CRM record is the same shape\n * of decision `describeEvent()` makes for `OMIT_FIELDS`: an absent mapping is\n * a stated design choice (the operator hasn't decided this field matters to\n * the CRM yet), never an error.\n */\nimport { identifySubmitter } from '../tracking/identity.ts';\nimport type { ConversionEvent } from '../tracking/types.ts';\nimport type { CrmConfig, CrmRecord } from './types.ts';\n\n/**\n * Bigin requires `Last_Name`; a form that only asked for a full name or an\n * email needs one derived rather than 400ing on insert.\n *\n * The fallback chain, in order: an explicit last name field → the last space-\n * separated token of a full name → the local part of the email address →\n * refuse, with a stated reason, rather than fabricate a name from nothing.\n * `map.ts` never emits an empty `lastName` for Bigin — `deliverToCrm` reads\n * the refusal and skips the write instead of letting the CRM reject it.\n */\nexport function deriveLastName(identity: {\n firstName?: string | undefined;\n lastName?: string | undefined;\n fullName: string;\n email?: string | undefined;\n}): { lastName: string } | { refusal: string } {\n if (identity.lastName) return { lastName: identity.lastName };\n\n const nameParts = identity.fullName.trim().split(/\\s+/);\n if (nameParts.length > 1) {\n return { lastName: nameParts[nameParts.length - 1]! };\n }\n\n if (identity.email) {\n const local = identity.email.split('@')[0];\n if (local) return { lastName: local };\n }\n\n return {\n refusal:\n 'Could not derive a last name from the submission (no last-name field, ' +\n 'no space in the full name, and no email to fall back to).',\n };\n}\n\nfunction mapExtra(\n formData: Record<string, string>,\n fieldMap: Record<string, string> | undefined,\n): Record<string, string> {\n const out: Record<string, string> = {};\n if (!fieldMap) return out;\n for (const [field, property] of Object.entries(fieldMap)) {\n const value = formData[field];\n if (value) out[property] = value;\n }\n return out;\n}\n\nfunction mapAttribution(\n event: ConversionEvent,\n trackingFields: Record<string, string> | undefined,\n): Record<string, string> {\n const out: Record<string, string> = {};\n // A visitor with no `_ac` cookie — organic arrival, or declined consent —\n // yields `{}`, never an error. Absence is not a mapping failure.\n if (!event.tracking || !trackingFields) return out;\n for (const [key, property] of Object.entries(trackingFields)) {\n const value = event.tracking[key];\n if (value !== undefined && value !== '') out[property] = String(value);\n }\n return out;\n}\n\nexport function mapSubmission(event: ConversionEvent, config: CrmConfig): CrmRecord {\n const formData = event.formData ?? {};\n const identity = identifySubmitter(formData);\n\n return {\n firstName: identity.firstName,\n lastName: identity.lastName,\n email: identity.email,\n phone: identity.phone ?? event.phone,\n company: identity.company,\n message: formData['message'] ?? formData['comments'] ?? formData['comment'],\n extra: mapExtra(formData, config.fieldMap),\n attribution: mapAttribution(event, config.trackingFields),\n };\n}\n","/**\n * Zoho OAuth2 refresh-token exchange, for Bigin.\n *\n * Not `google/token.ts`: that module imports `node:crypto` to sign the\n * service-account JWT assertion, which makes it unusable in a Worker. Zoho's\n * self-client grant is a plain `application/x-www-form-urlencoded` POST, so\n * this module has no such dependency and can run on every submission.\n *\n * Cached per isolate, keyed on the refresh token, with an expiry margin — a\n * Worker isolate can field many submissions before it's recycled, and minting\n * a fresh access token on every one would be a needless round trip Bigin's\n * own rate limit does not forgive kindly.\n */\n\nexport interface ZohoDataCenterHosts {\n accounts: string;\n}\n\nconst ZOHO_ACCOUNTS_HOSTS: Record<string, string> = {\n com: 'accounts.zoho.com',\n ca: 'accounts.zohocloud.ca',\n eu: 'accounts.zoho.eu',\n in: 'accounts.zoho.in',\n 'com.au': 'accounts.zoho.com.au',\n jp: 'accounts.zoho.jp',\n};\n\nexport function zohoAccountsHost(dataCenter: string): string {\n return ZOHO_ACCOUNTS_HOSTS[dataCenter] ?? ZOHO_ACCOUNTS_HOSTS.com!;\n}\n\n/** Refresh a few seconds before Zoho actually expires it, so a token that's\n * about to lapse mid-request is never handed out. */\nconst EXPIRY_MARGIN_MS = 60_000;\n\ninterface CachedToken {\n accessToken: string;\n expiresAt: number;\n}\n\nconst cache = new Map<string, CachedToken>();\n\nexport interface ZohoOAuth {\n clientId: string;\n clientSecret: string;\n refreshToken: string;\n}\n\n/**\n * Exchange a refresh token for a short-lived Bigin access token, reusing a\n * cached one while it's still valid.\n *\n * `now` is a parameter so cache expiry is testable without a fake clock, the\n * same convention `google/token.ts`'s `googleAccessToken` uses.\n */\nexport async function zohoAccessToken(\n oauth: ZohoOAuth,\n dataCenter: string,\n fetchImpl: typeof fetch = fetch,\n now: number = Date.now(),\n): Promise<string> {\n const cached = cache.get(oauth.refreshToken);\n if (cached && cached.expiresAt > now) return cached.accessToken;\n\n const body = new URLSearchParams({\n grant_type: 'refresh_token',\n client_id: oauth.clientId,\n client_secret: oauth.clientSecret,\n refresh_token: oauth.refreshToken,\n });\n\n const res = await fetchImpl(`https://${zohoAccountsHost(dataCenter)}/oauth/v2/token`, {\n method: 'POST',\n headers: { 'content-type': 'application/x-www-form-urlencoded' },\n body: body.toString(),\n });\n\n if (!res.ok) {\n throw new Error(`Zoho token exchange failed (${res.status}): ${await res.text()}`);\n }\n\n const json = (await res.json()) as { access_token?: string; expires_in?: number; error?: string };\n if (!json.access_token) {\n throw new Error(\n `Zoho token exchange returned no access_token: ${json.error ?? 'unknown error'}`,\n );\n }\n\n const expiresAt = now + (json.expires_in ?? 3600) * 1000 - EXPIRY_MARGIN_MS;\n cache.set(oauth.refreshToken, { accessToken: json.access_token, expiresAt });\n return json.access_token;\n}\n\n/** Test-only: clear the module-level cache between cases. */\nexport function clearZohoTokenCache(): void {\n cache.clear();\n}\n","/**\n * Zoho Bigin executor — decides nothing except the one thing it must refuse\n * to send: a record with no derivable last name (see `deriveLastName` in\n * `map.ts`). Everything else is \"build the request, post it, classify the\n * response.\"\n *\n * `upsert` with `duplicate_check_fields: ['Email']` (configurable) rather than\n * plain `Contacts` create — a visitor resubmitting the same form (a second\n * enquiry, a retried failed submission) should update one contact, not create\n * a duplicate every time.\n */\nimport { deriveLastName } from './map.ts';\nimport type { BiginConfig, BiginCredentials, CrmRecord, CrmResult } from './types.ts';\nimport { zohoAccessToken } from './zoho-token.ts';\n\nconst ZOHO_API_HOSTS: Record<string, string> = {\n com: 'www.zohoapis.com',\n ca: 'www.zohoapis.ca',\n eu: 'www.zohoapis.eu',\n in: 'www.zohoapis.in',\n 'com.au': 'www.zohoapis.com.au',\n jp: 'www.zohoapis.jp',\n};\n\nexport function zohoApiHost(dataCenter: string): string {\n return ZOHO_API_HOSTS[dataCenter] ?? ZOHO_API_HOSTS.com!;\n}\n\nfunction biginFields(record: CrmRecord, lastName: string): Record<string, unknown> {\n return {\n Last_Name: lastName,\n ...(record.firstName ? { First_Name: record.firstName } : {}),\n ...(record.email ? { Email: record.email } : {}),\n ...(record.phone ? { Phone: record.phone } : {}),\n ...(record.company ? { Account_Name: record.company } : {}),\n ...(record.message ? { Description: record.message } : {}),\n ...record.extra,\n ...record.attribution,\n Lead_Source: 'Website',\n };\n}\n\n/**\n * Write one record to Bigin. Never throws — a token-exchange failure, a\n * network error and a rejected response all come back as `CrmResult`.\n */\nexport async function submitToBigin(\n record: CrmRecord,\n config: BiginConfig,\n credentials: BiginCredentials,\n fetchImpl: typeof fetch = fetch,\n timeoutMs = 5_000,\n): Promise<CrmResult> {\n const derived = deriveLastName({\n firstName: record.firstName,\n lastName: record.lastName,\n fullName: [record.firstName, record.lastName].filter(Boolean).join(' ') || record.email || '',\n email: record.email,\n });\n if ('refusal' in derived) {\n return { ok: false, provider: 'bigin', reason: derived.refusal };\n }\n\n let token: string;\n try {\n token = await zohoAccessToken(credentials, config.dataCenter, fetchImpl);\n } catch (err) {\n return {\n ok: false,\n provider: 'bigin',\n reason: `Zoho token exchange failed: ${err instanceof Error ? err.message : String(err)}`,\n };\n }\n\n const host = zohoApiHost(config.dataCenter);\n const url = `https://${host}/bigin/v2/Contacts/upsert`;\n const body = {\n data: [biginFields(record, derived.lastName)],\n duplicate_check_fields: [config.duplicateCheckField ?? 'Email'],\n trigger: ['workflow'],\n };\n\n let res: Response;\n try {\n res = await fetchImpl(url, {\n method: 'POST',\n headers: {\n 'content-type': 'application/json',\n authorization: `Zoho-oauthtoken ${token}`,\n },\n body: JSON.stringify(body),\n signal: AbortSignal.timeout(timeoutMs),\n });\n } catch (err) {\n return {\n ok: false,\n provider: 'bigin',\n reason: `Bigin request failed: ${err instanceof Error ? err.message : String(err)}`,\n };\n }\n\n const text = await res.text().catch(() => '');\n if (!res.ok) {\n return {\n ok: false,\n provider: 'bigin',\n code: String(res.status),\n reason: `Bigin responded ${res.status}: ${text}`,\n };\n }\n\n let json: { data?: { code?: string; status?: string; details?: { id?: string } }[] };\n try {\n json = JSON.parse(text);\n } catch {\n return {\n ok: false,\n provider: 'bigin',\n reason: `Bigin returned an unparsable response: ${text}`,\n };\n }\n\n const entry = json.data?.[0];\n if (!entry || entry.status !== 'success') {\n return {\n ok: false,\n provider: 'bigin',\n code: entry?.code,\n reason: `Bigin upsert did not succeed: ${text}`,\n };\n }\n\n const contactId = entry.details?.id;\n\n // The pipeline write is additive and best-effort: the contact already\n // exists, so a failure here is a lost pipeline card, not a lost lead. It\n // never downgrades the overall `ok: true` the contact write already earned.\n if (config.pipeline?.enabled && contactId) {\n await createPipelineRecord(record, config, contactId, host, token, fetchImpl, timeoutMs).catch(\n () => undefined,\n );\n }\n\n return { ok: true, provider: 'bigin', recordId: contactId };\n}\n\nasync function createPipelineRecord(\n record: CrmRecord,\n config: BiginConfig,\n contactId: string,\n host: string,\n token: string,\n fetchImpl: typeof fetch,\n timeoutMs: number,\n): Promise<void> {\n const pipeline = config.pipeline!;\n const body = {\n data: [\n {\n Pipeline_Name: record.company || record.email || 'Website enquiry',\n Contact_Name: contactId,\n ...(pipeline.subPipeline ? { Sub_Pipeline: pipeline.subPipeline } : {}),\n ...(pipeline.stage ? { Stage: pipeline.stage } : {}),\n },\n ],\n trigger: ['workflow'],\n };\n await fetchImpl(`https://${host}/bigin/v2/Pipelines`, {\n method: 'POST',\n headers: {\n 'content-type': 'application/json',\n authorization: `Zoho-oauthtoken ${token}`,\n },\n body: JSON.stringify(body),\n signal: AbortSignal.timeout(timeoutMs),\n });\n}\n","/**\n * HubSpot executor — decides nothing, posts what it's told.\n *\n * Uses the **secure** submissions endpoint (`/submissions/v3/integration/\n * secure/submit/{portalId}/{formGuid}`, private-app Bearer token) rather than\n * the public forms-embed endpoint. That distinction is load-bearing: posting\n * to the secure endpoint registers a real *form submission* against the\n * portal's form, so HubSpot's own workflows, lifecycle-stage automation and\n * attribution reporting fire exactly as if the client's hosted form had been\n * used — the public endpoint has no such effect and is also the one that\n * rejects a field not already declared on the form (see `CrmRecord`'s\n * drop-if-unmapped rule, which exists partly because of that).\n */\nimport type { CrmRecord, CrmResult, HubSpotConfig, HubSpotCredentials } from './types.ts';\n\nconst HUBSPOT_HOSTS: Record<NonNullable<HubSpotConfig['dataCenter']>, string> = {\n na: 'api.hsforms.com',\n eu: 'api-eu1.hsforms.com',\n};\n\ninterface HubSpotField {\n objectTypeId: '0-1';\n name: string;\n value: string;\n}\n\nfunction fieldsFor(record: CrmRecord): HubSpotField[] {\n const fields: HubSpotField[] = [];\n const push = (name: string, value: string | undefined) => {\n if (value) fields.push({ objectTypeId: '0-1', name, value });\n };\n push('firstname', record.firstName);\n push('lastname', record.lastName);\n push('email', record.email);\n push('phone', record.phone);\n push('company', record.company);\n push('message', record.message);\n for (const [name, value] of Object.entries(record.extra)) push(name, value);\n for (const [name, value] of Object.entries(record.attribution)) push(name, value);\n return fields;\n}\n\nexport interface HubSpotSubmitContext {\n hutk?: string | undefined;\n pageUri?: string | undefined;\n pageName?: string | undefined;\n}\n\n/**\n * Submit one record to HubSpot's secure forms endpoint.\n *\n * Never throws — a network failure or an unparsable response both come back\n * as a `CrmResult` with `ok: false`, the same \"structured result, not an\n * exception\" rule `security/turnstile.ts` and `indexing/gsc.ts` follow. Status\n * classification: 401 is a rejected/expired token; 400 naming\n * `MISSING_REQUIRED_FIELDS` or an unrecognised field name is a mapping\n * problem (`fieldMap`/`trackingFields` naming a property that isn't on the\n * form); 429 is the per-portal rate limit.\n */\nexport async function submitToHubSpot(\n record: CrmRecord,\n config: HubSpotConfig,\n credentials: HubSpotCredentials,\n context: HubSpotSubmitContext = {},\n fetchImpl: typeof fetch = fetch,\n timeoutMs = 5_000,\n): Promise<CrmResult> {\n const host = HUBSPOT_HOSTS[config.dataCenter ?? 'na'];\n const url = `https://${host}/submissions/v3/integration/secure/submit/${config.portalId}/${config.formGuid}`;\n\n const body = {\n fields: fieldsFor(record),\n context: {\n ...(context.hutk ? { hutk: context.hutk } : {}),\n ...(context.pageUri ? { pageUri: context.pageUri } : {}),\n ...(context.pageName ? { pageName: context.pageName } : {}),\n },\n };\n\n let res: Response;\n try {\n res = await fetchImpl(url, {\n method: 'POST',\n headers: {\n 'content-type': 'application/json',\n authorization: `Bearer ${credentials.accessToken}`,\n },\n body: JSON.stringify(body),\n signal: AbortSignal.timeout(timeoutMs),\n });\n } catch (err) {\n return {\n ok: false,\n provider: 'hubspot',\n reason: `HubSpot request failed: ${err instanceof Error ? err.message : String(err)}`,\n };\n }\n\n if (res.ok) {\n return { ok: true, provider: 'hubspot' };\n }\n\n const text = await res.text().catch(() => '');\n\n if (res.status === 401) {\n return {\n ok: false,\n provider: 'hubspot',\n code: '401',\n reason: `HubSpot rejected the access token: ${text}`,\n };\n }\n if (res.status === 429) {\n return {\n ok: false,\n provider: 'hubspot',\n code: '429',\n reason: 'HubSpot rate limit exceeded for this portal.',\n };\n }\n if (\n res.status === 400 &&\n /MISSING_REQUIRED_FIELDS|not.*(a|an).*(valid|known).*field/i.test(text)\n ) {\n return {\n ok: false,\n provider: 'hubspot',\n code: '400',\n reason: `HubSpot rejected the mapped fields — check \\`fieldMap\\`/\\`trackingFields\\` against the form's own fields: ${text}`,\n };\n }\n\n return {\n ok: false,\n provider: 'hubspot',\n code: String(res.status),\n reason: `HubSpot responded ${res.status}: ${text}`,\n };\n}\n","/**\n * Jobber OAuth2 refresh-token exchange.\n *\n * A plain `application/x-www-form-urlencoded` POST, same shape as Zoho's —\n * see `zoho-token.ts` for why that keeps this Worker-safe (no `node:crypto`,\n * unlike `google/token.ts`'s service-account JWT).\n *\n * One thing Zoho's grant never does: Jobber's Refresh Token Rotation is ON by\n * default for a new app, and when it's on, every exchange returns a *new*\n * refresh token and invalidates the old one. A Worker reading a static env\n * var (`VITOPS_JOBBER_REFRESH_TOKEN`) cannot persist that new value, so this\n * integration requires rotation turned OFF in the app's Developer Center\n * settings — a legitimate setup requirement, since a per-client private app\n * is never published to Jobber's Marketplace (where rotation ON is\n * mandatory). `zohoAccessToken`'s Zoho equivalent has no such failure mode to\n * detect, because Zoho's grant is long-lived by default.\n *\n * Cached per isolate, keyed on the refresh token, with an expiry margin —\n * same rationale as `zoho-token.ts`.\n */\n\nconst JOBBER_OAUTH_TOKEN_URL = 'https://api.getjobber.com/api/oauth/token';\n\n/** Refresh a few seconds before Jobber actually expires it (access tokens\n * last 60 minutes), so a token that's about to lapse mid-request is never\n * handed out. */\nconst EXPIRY_MARGIN_MS = 60_000;\n\ninterface CachedToken {\n accessToken: string;\n expiresAt: number;\n}\n\nconst cache = new Map<string, CachedToken>();\n\nexport interface JobberOAuth {\n clientId: string;\n clientSecret: string;\n refreshToken: string;\n}\n\n/**\n * Exchange a refresh token for a short-lived Jobber access token, reusing a\n * cached one while it's still valid.\n *\n * Throws a specific, actionable error when Jobber's response carries a\n * *different* refresh token than the one sent — the rotation case above.\n * Without this check, rotation surfaces an hour later as an opaque 401 on a\n * lead that is already lost; with it, the first submission after rotation\n * turns on names the fix.\n *\n * `now` is a parameter so cache expiry is testable without a fake clock, the\n * same convention `zoho-token.ts`'s `zohoAccessToken` uses.\n */\nexport async function jobberAccessToken(\n oauth: JobberOAuth,\n fetchImpl: typeof fetch = fetch,\n now: number = Date.now(),\n): Promise<string> {\n const cached = cache.get(oauth.refreshToken);\n if (cached && cached.expiresAt > now) return cached.accessToken;\n\n const body = new URLSearchParams({\n grant_type: 'refresh_token',\n client_id: oauth.clientId,\n client_secret: oauth.clientSecret,\n refresh_token: oauth.refreshToken,\n });\n\n const res = await fetchImpl(JOBBER_OAUTH_TOKEN_URL, {\n method: 'POST',\n headers: { 'content-type': 'application/x-www-form-urlencoded' },\n body: body.toString(),\n });\n\n if (!res.ok) {\n throw new Error(`Jobber token exchange failed (${res.status}): ${await res.text()}`);\n }\n\n const json = (await res.json()) as {\n access_token?: string;\n refresh_token?: string;\n expires_in?: number;\n error?: string;\n };\n if (!json.access_token) {\n throw new Error(\n `Jobber token exchange returned no access_token: ${json.error ?? 'unknown error'}`,\n );\n }\n if (json.refresh_token && json.refresh_token !== oauth.refreshToken) {\n throw new Error(\n 'Jobber returned a rotated refresh token. Turn Refresh Token Rotation off for this app ' +\n \"in Jobber's Developer Center — a rotated token cannot be persisted from a stateless \" +\n 'Worker reading a static VITOPS_JOBBER_REFRESH_TOKEN.',\n );\n }\n\n const expiresAt = now + (json.expires_in ?? 3600) * 1000 - EXPIRY_MARGIN_MS;\n cache.set(oauth.refreshToken, { accessToken: json.access_token, expiresAt });\n return json.access_token;\n}\n\n/** Test-only: clear the module-level cache between cases. */\nexport function clearJobberTokenCache(): void {\n cache.clear();\n}\n","/**\n * Jobber executor — a GraphQL API, unlike both existing REST executors\n * (`hubspot.ts`, `bigin.ts`), and that difference is why this file classifies\n * differently from either of them.\n *\n * **GraphQL answers `200 OK` for failures.** A malformed query or a\n * permission/version problem returns a top-level `errors[]`; a rejected\n * mutation input returns `data.clientCreate.userErrors[]` — both with HTTP\n * 200. So classification here is four layers, in order: transport error →\n * HTTP status (401 credential, 429 rate limit) → top-level `errors[]` (a\n * query/permission/version problem) → `userErrors[]` (a mapping problem: an\n * unknown field, a missing required attribute). Only past all four is\n * `ok: true`.\n *\n * **Only `clientCreate` is verified against Jobber's own published docs.**\n * Two shapes here are NOT: `noteCreate` (Jobber's changelog records\n * `requestNoteCreate`/`requestUpsert` being *removed*, so the note surface is\n * genuinely in flux) and the `clients` query's dedupe filter argument. Until\n * both are checked against a live account's GraphiQL schema:\n * - the note write is wrapped in `.catch(() => undefined)`, exactly like\n * Bigin's `createPipelineRecord` — a failure here loses the note, never\n * the already-written client, and never downgrades `ok: true`;\n * - `dedupe` defaults to `false` rather than the eventually-intended `true`,\n * so a misshapen filter never silently blocks a real submission. Turning\n * it on is safe once the query is confirmed; document the confirmed\n * shape in this docblock when that happens.\n *\n * Attribution (`record.attribution`) and unmapped extras (`record.extra`)\n * are rendered into the note body by default — that works on every Jobber\n * plan. `config.jobber` carries no separate custom-field wiring in this\n * pass: Jobber's app-configured custom fields need the Connect plan or\n * higher and their `clientCreate` input shape is a further unverified\n * surface, so `fieldMap`/`trackingFields` values still land as note lines\n * rather than a guessed custom-field mutation input.\n */\nimport type { CrmRecord, CrmResult, JobberConfig, JobberCredentials } from './types.ts';\nimport { jobberAccessToken } from './jobber-token.ts';\n\nconst JOBBER_GRAPHQL_ENDPOINT = 'https://api.getjobber.com/api/graphql';\n\n/** Pinned to the version this integration was built and tested against.\n * Jobber's `X-JOBBER-GRAPHQL-VERSION` header is mandatory — an unversioned\n * request is rejected outright — so this is code, not something left to\n * config to supply by default. `config.jobber.apiVersion` is the escape\n * hatch for a client's app pinned to a different one. */\nconst JOBBER_API_VERSION = '2025-04-16';\n\ninterface JobberErrorItem {\n message: string;\n}\n\ninterface ClientCreateResponse {\n data?: {\n clientCreate?: {\n client?: { id: string } | null;\n userErrors?: JobberErrorItem[];\n } | null;\n };\n errors?: JobberErrorItem[];\n}\n\nfunction clientCreateInput(record: CrmRecord): Record<string, unknown> {\n return {\n ...(record.firstName ? { firstName: record.firstName } : {}),\n ...(record.lastName ? { lastName: record.lastName } : {}),\n ...(record.company ? { companyName: record.company } : {}),\n ...(record.email\n ? { emails: [{ description: 'MAIN', primary: true, address: record.email }] }\n : {}),\n ...(record.phone\n ? { phones: [{ description: 'MAIN', primary: true, number: record.phone }] }\n : {}),\n };\n}\n\n/** Note body: the submitted message plus every attribution/extra field that\n * has no dedicated Jobber field, so nothing is lost to the note-vs-field\n * split above. Empty sections are omitted rather than rendered blank. */\nfunction noteBody(record: CrmRecord, pageUri: string | undefined): string {\n const lines: string[] = [];\n if (record.message) lines.push(record.message);\n const extraEntries = Object.entries(record.extra);\n if (extraEntries.length) {\n lines.push('', ...extraEntries.map(([k, v]) => `${k}: ${v}`));\n }\n const attributionEntries = Object.entries(record.attribution);\n if (attributionEntries.length) {\n lines.push('', 'Attribution:', ...attributionEntries.map(([k, v]) => `${k}: ${v}`));\n }\n if (pageUri) lines.push('', `Submitted from ${pageUri}`);\n return lines.join('\\n').trim();\n}\n\nasync function graphql(\n query: string,\n variables: Record<string, unknown>,\n token: string,\n apiVersion: string,\n fetchImpl: typeof fetch,\n timeoutMs: number,\n): Promise<Response> {\n return fetchImpl(JOBBER_GRAPHQL_ENDPOINT, {\n method: 'POST',\n headers: {\n 'content-type': 'application/json',\n authorization: `Bearer ${token}`,\n 'x-jobber-graphql-version': apiVersion,\n },\n body: JSON.stringify({ query, variables }),\n signal: AbortSignal.timeout(timeoutMs),\n });\n}\n\nconst CLIENT_CREATE_MUTATION = `\n mutation CrmClientCreate($input: ClientCreateInput!) {\n clientCreate(input: $input) {\n client { id }\n userErrors { message }\n }\n }\n`;\n\n/** Best-effort — see the module docblock's note on the unverified\n * `noteCreate` shape. Any failure (transport, GraphQL error, or the mutation\n * simply not existing under this name) is swallowed by the caller. */\nconst NOTE_CREATE_MUTATION = `\n mutation CrmNoteCreate($input: NoteCreateInput!) {\n noteCreate(input: $input) {\n note { id }\n userErrors { message }\n }\n }\n`;\n\nasync function createNote(\n clientId: string,\n message: string,\n token: string,\n apiVersion: string,\n fetchImpl: typeof fetch,\n timeoutMs: number,\n): Promise<void> {\n if (!message) return;\n const res = await graphql(\n NOTE_CREATE_MUTATION,\n { input: { clientId, message } },\n token,\n apiVersion,\n fetchImpl,\n timeoutMs,\n );\n const json = (await res.json()) as {\n errors?: JobberErrorItem[];\n data?: { noteCreate?: { userErrors?: JobberErrorItem[] } };\n };\n if (json.errors?.length) throw new Error(json.errors.map((e) => e.message).join('; '));\n const userErrors = json.data?.noteCreate?.userErrors;\n if (userErrors?.length) throw new Error(userErrors.map((e) => e.message).join('; '));\n}\n\n/** Best-effort — see the module docblock's note on the unverified `clients`\n * filter shape. A failure (or the field simply not matching this account's\n * schema) means \"could not dedupe\", not \"stop the submission\" — falls\n * through to creating a new client. */\nconst FIND_CLIENT_BY_EMAIL_QUERY = `\n query CrmFindClientByEmail($searchTerm: String!) {\n clients(first: 1, searchTerm: $searchTerm) {\n nodes { id }\n }\n }\n`;\n\nasync function findExistingClientId(\n email: string,\n token: string,\n apiVersion: string,\n fetchImpl: typeof fetch,\n timeoutMs: number,\n): Promise<string | undefined> {\n const res = await graphql(\n FIND_CLIENT_BY_EMAIL_QUERY,\n { searchTerm: email },\n token,\n apiVersion,\n fetchImpl,\n timeoutMs,\n );\n const json = (await res.json()) as {\n errors?: JobberErrorItem[];\n data?: { clients?: { nodes?: { id: string }[] } };\n };\n if (json.errors?.length) throw new Error(json.errors.map((e) => e.message).join('; '));\n return json.data?.clients?.nodes?.[0]?.id;\n}\n\n/**\n * Write one record to Jobber. Never throws — a token-exchange failure, a\n * network error, a top-level GraphQL `errors[]`, and a `userErrors[]` all\n * come back as a `CrmResult` with `ok: false`.\n */\nexport async function submitToJobber(\n record: CrmRecord,\n config: JobberConfig,\n credentials: JobberCredentials,\n pageUri: string | undefined,\n fetchImpl: typeof fetch = fetch,\n timeoutMs = 5_000,\n): Promise<CrmResult> {\n const apiVersion = config.apiVersion ?? JOBBER_API_VERSION;\n\n let token: string;\n try {\n token = await jobberAccessToken(credentials, fetchImpl);\n } catch (err) {\n return {\n ok: false,\n provider: 'jobber',\n reason: `Jobber token exchange failed: ${err instanceof Error ? err.message : String(err)}`,\n };\n }\n\n // Dedupe defaults to false until the `clients` filter shape above is\n // verified against a live schema — see the module docblock.\n if (config.dedupe === true && record.email) {\n try {\n const existingId = await findExistingClientId(\n record.email,\n token,\n apiVersion,\n fetchImpl,\n timeoutMs,\n );\n if (existingId) {\n if (config.note?.enabled !== false) {\n await createNote(\n existingId,\n noteBody(record, pageUri),\n token,\n apiVersion,\n fetchImpl,\n timeoutMs,\n ).catch(() => undefined);\n }\n return { ok: true, provider: 'jobber', recordId: existingId };\n }\n } catch {\n // Could not dedupe — proceed to create rather than fail the submission.\n }\n }\n\n let res: Response;\n try {\n res = await graphql(\n CLIENT_CREATE_MUTATION,\n { input: clientCreateInput(record) },\n token,\n apiVersion,\n fetchImpl,\n timeoutMs,\n );\n } catch (err) {\n return {\n ok: false,\n provider: 'jobber',\n reason: `Jobber request failed: ${err instanceof Error ? err.message : String(err)}`,\n };\n }\n\n if (res.status === 401) {\n return {\n ok: false,\n provider: 'jobber',\n code: '401',\n reason: `Jobber rejected the access token: ${await res.text().catch(() => '')}`,\n };\n }\n if (res.status === 429) {\n return {\n ok: false,\n provider: 'jobber',\n code: '429',\n reason: 'Jobber rate limit exceeded (2,500 requests per 5 minutes).',\n };\n }\n if (!res.ok) {\n return {\n ok: false,\n provider: 'jobber',\n code: String(res.status),\n reason: `Jobber responded ${res.status}: ${await res.text().catch(() => '')}`,\n };\n }\n\n let json: ClientCreateResponse;\n try {\n json = (await res.json()) as ClientCreateResponse;\n } catch {\n return { ok: false, provider: 'jobber', reason: 'Jobber returned an unparsable response.' };\n }\n\n if (json.errors?.length) {\n return {\n ok: false,\n provider: 'jobber',\n reason: `Jobber rejected the request: ${json.errors.map((e) => e.message).join('; ')}`,\n };\n }\n\n const userErrors = json.data?.clientCreate?.userErrors;\n if (userErrors?.length) {\n return {\n ok: false,\n provider: 'jobber',\n reason: `Jobber rejected the mapped fields: ${userErrors.map((e) => e.message).join('; ')}`,\n };\n }\n\n const clientId = json.data?.clientCreate?.client?.id;\n if (!clientId) {\n return { ok: false, provider: 'jobber', reason: 'Jobber did not return a client id.' };\n }\n\n if (config.note?.enabled !== false) {\n await createNote(\n clientId,\n noteBody(record, pageUri),\n token,\n apiVersion,\n fetchImpl,\n timeoutMs,\n ).catch(() => undefined);\n }\n\n return { ok: true, provider: 'jobber', recordId: clientId };\n}\n","/**\n * Every decision about *whether* a submission reaches a CRM, as one pure\n * function.\n *\n * Pure in the load-bearing sense: no `fetch`, no clock, no credential lookup\n * beyond what the caller already resolved. Mirrors `notify/plan.ts`'s\n * `ChannelPlan` shape — `enabled`, or `false` with a stated `skip` — so `--dry`\n * is a complete account and a misconfigured site can be told exactly why\n * nothing will be written, rather than discovering it as a silently missing\n * contact.\n *\n * What this file does NOT decide: whether *this* submission's write succeeds.\n * That is `map.ts` (shaping the data) and the executors (`hubspot.ts`/\n * `bigin.ts`/`jobber.ts`, making the call) — this only decides whether the\n * channel is on at all, from config and credentials.\n */\nimport type { CrmConfig, CrmCredentials, CrmPlan, CrmProvider } from './types.ts';\n\n/**\n * What each provider requires to be considered configured, as one table\n * rather than a per-provider `if`. A provider missing here is a type error —\n * `Record<CrmProvider, …>` — rather than a silently-enabled channel with no\n * config block, which is what the equivalent hand-written checks would do for\n * a provider nobody added a branch for.\n */\nconst PROVIDER_REQUIREMENTS: Record<\n CrmProvider,\n { block: 'hubspot' | 'bigin' | 'jobber'; credentialHint: string }\n> = {\n hubspot: { block: 'hubspot', credentialHint: 'No HubSpot access token configured.' },\n bigin: { block: 'bigin', credentialHint: 'No Zoho OAuth credentials configured.' },\n jobber: { block: 'jobber', credentialHint: 'No Jobber OAuth credentials configured.' },\n};\n\nexport function planCrm(\n config: CrmConfig | undefined,\n credentials: CrmCredentials | undefined,\n): CrmPlan {\n if (!config) {\n return { enabled: false, skip: 'No `crm` configured.' };\n }\n\n if (config.enabled === false) {\n return { enabled: false, provider: config.provider, skip: '`crm.enabled` is false.' };\n }\n\n const requirement = PROVIDER_REQUIREMENTS[config.provider];\n if (!config[requirement.block]) {\n return {\n enabled: false,\n provider: config.provider,\n skip: `\\`crm.provider\\` is \"${config.provider}\" but \\`crm.${requirement.block}\\` is missing.`,\n };\n }\n\n if (!credentials) {\n return {\n enabled: false,\n provider: config.provider,\n skip: requirement.credentialHint,\n };\n }\n\n if (credentials.provider !== config.provider) {\n return {\n enabled: false,\n provider: config.provider,\n skip: `\\`crm.provider\\` is \"${config.provider}\" but the supplied credentials are for \"${credentials.provider}\".`,\n };\n }\n\n return { enabled: true, provider: config.provider };\n}\n","/**\n * The CRM module's own option surface.\n *\n * Deliberately structural rather than `Config['site']['crm']` imported from\n * `@getvitops/generator`: the generator already depends on this package, so\n * importing back would be a cycle. The vocabulary mirrors that block field for\n * field — the same arrangement, and for the same reason, as `IndexingConfig`\n * and `NotifyContext` beside it.\n *\n * Optional fields are written `?: T | undefined` throughout: `exactOptionalPropertyTypes`\n * is on, and these describe a *parsed JSON config* where an absent key and an\n * explicit `undefined` are the same thing.\n */\n\nexport type CrmProvider = 'hubspot' | 'bigin' | 'jobber';\n\n/** Human-readable label per provider, for the notification email's Delivery\n * line (`describeCrmResult`) and anywhere else a provider must be named\n * without a hand-written ternary. Exhaustive by type — a provider missing\n * here is a compile error, not a mislabelled email. */\nexport const PROVIDER_LABELS: Record<CrmProvider, string> = {\n hubspot: 'HubSpot',\n bigin: 'Bigin',\n jobber: 'Jobber',\n};\n\n/** Bigin's data centre — the region a self-client's tokens and API host belong to. */\nexport type BiginDataCenter = 'com' | 'ca' | 'eu' | 'in' | 'com.au' | 'jp';\n\nexport interface HubSpotConfig {\n portalId: string;\n formGuid: string;\n /** Default `na`. Only affects the submissions API host. */\n dataCenter?: 'na' | 'eu' | undefined;\n}\n\nexport interface BiginPipelineConfig {\n enabled: boolean;\n subPipeline?: string | undefined;\n stage?: string | undefined;\n}\n\nexport interface BiginConfig {\n dataCenter: BiginDataCenter;\n /** Default `Email` — Bigin's own unique field, used for `upsert`'s dedupe. */\n duplicateCheckField?: string | undefined;\n pipeline?: BiginPipelineConfig | undefined;\n}\n\nexport interface JobberConfig {\n /**\n * The pinned `X-JOBBER-GRAPHQL-VERSION` date this integration was built and\n * tested against. Overriding it is an escape hatch for a client's app\n * pinned to a different version — not something to reach for casually,\n * since Jobber's schema can change field names between versions.\n */\n apiVersion?: string | undefined;\n /**\n * Look up an existing client by email before creating one (default\n * **false**). Jobber's `clientCreate` has no upsert, so a resubmitting\n * visitor creates a second client unless this runs — but the lookup query's\n * filter shape is not yet verified against a live Jobber schema (see\n * `jobber.ts`'s docblock), so it defaults off rather than risk a wrong\n * query silently blocking a real submission. Set `true` once verified for\n * your account.\n */\n dedupe?: boolean | undefined;\n note?:\n | {\n /** Attach a best-effort note carrying the message + unmapped\n * attribution (default true). A failure here never downgrades the\n * client write's own success — see `jobber.ts`. */\n enabled?: boolean | undefined;\n }\n | undefined;\n}\n\nexport interface CrmConfig {\n provider: CrmProvider;\n enabled?: boolean | undefined;\n hubspot?: HubSpotConfig | undefined;\n bigin?: BiginConfig | undefined;\n jobber?: JobberConfig | undefined;\n /** Submitted field name → CRM property name. Unmapped fields are dropped, not guessed. */\n fieldMap?: Record<string, string> | undefined;\n /** `_ac` tracking key → CRM property name. Same drop-if-unmapped rule. */\n trackingFields?: Record<string, string> | undefined;\n}\n\n/** Credentials, never config — always injected by the caller from env vars. */\nexport interface HubSpotCredentials {\n accessToken: string;\n}\n\nexport interface BiginCredentials {\n clientId: string;\n clientSecret: string;\n refreshToken: string;\n}\n\n/**\n * Jobber's self-client OAuth credential. The refresh token is expected to be\n * static — Refresh Token Rotation must be OFF in the app's Developer Center\n * settings, since a Worker reading a static env var cannot persist a rotated\n * one. See `jobber-token.ts` for the detection when a client's app has it on\n * anyway.\n */\nexport interface JobberCredentials {\n clientId: string;\n clientSecret: string;\n refreshToken: string;\n}\n\nexport type CrmCredentials =\n | { provider: 'hubspot'; hubspot: HubSpotCredentials }\n | { provider: 'bigin'; bigin: BiginCredentials }\n | { provider: 'jobber'; jobber: JobberCredentials };\n\n/** A channel's decision: on, or off with a stated reason. Mirrors `ChannelPlan`. */\nexport interface CrmPlan {\n enabled: boolean;\n provider?: CrmProvider | undefined;\n /** Why it's off. Present iff `enabled` is false. */\n skip?: string | undefined;\n}\n\n/** A provider-neutral record — what a submission maps to, before either executor\n * renders it into that CRM's own request shape. */\nexport interface CrmRecord {\n firstName?: string | undefined;\n lastName?: string | undefined;\n email?: string | undefined;\n phone?: string | undefined;\n company?: string | undefined;\n message?: string | undefined;\n /** Mapped fields beyond the identity ones above, keyed by the CRM property name. */\n extra: Record<string, string>;\n /** `trackingFields`-mapped attribution, keyed by the CRM property name. Empty\n * when the visitor arrived with no `_ac` cookie — never an error. */\n attribution: Record<string, string>;\n}\n\n/**\n * The outcome of one delivery attempt. Never thrown — a CRM write is\n * best-effort and its failure is a fact for the caller to log or notify with,\n * not an exception to propagate into the request that produced the submission.\n */\nexport interface CrmResult {\n ok: boolean;\n provider: CrmProvider;\n /** The created/updated record's id, when the CRM returned one. */\n recordId?: string | undefined;\n /** Why it didn't write, or what the classification of the failure was. */\n reason?: string | undefined;\n /** The provider's own error/status code, when it gave one. */\n code?: string | undefined;\n}\n\n/** What `mapSubmission` needs, gathered by the caller once per site. */\nexport interface CrmMapContext {\n config: CrmConfig;\n}\n","/**\n * CRM form-submission delivery — plan, map, dispatch.\n *\n * The seam between \"a form was submitted\" and \"there's a contact in the\n * client's CRM for it.\" A `ConversionEvent` from `@getvitops/utils/tracking`\n * goes in — the same event `notify` already turns into an email — and where\n * it goes and how it's shaped are config, not code at the call site.\n *\n * Pure-planner / executor split, matching `notify/`, `indexing/`, `ads/`:\n * - `plan.ts` decides whether the channel is on at all, purely.\n * - `map.ts` decides how one submission becomes a `CrmRecord`, purely.\n * - `hubspot.ts` / `bigin.ts` / `jobber.ts` execute, and decide nothing\n * beyond the one thing Bigin must refuse (see `deriveLastName`) and the\n * four-layer classification `jobber.ts`'s GraphQL transport needs.\n *\n * `deliverToCrm` never throws: a CRM outage must not lose a lead, so the\n * caller (`createConversionRoute`) always gets a `CrmResult` back to log or\n * fold into the notification email, never an exception to catch.\n *\n * TODO — no generic connector framework. Three CRMs with three genuinely\n * different transports (a form-submission POST, a REST upsert, a GraphQL\n * mutation) is evidence against a shared abstraction, not for one — matching\n * `notify`'s own restraint about its unimplemented channels.\n */\nimport type { ConversionEvent } from '../tracking/types.ts';\nimport { submitToBigin } from './bigin.ts';\nimport { submitToHubSpot, type HubSpotSubmitContext } from './hubspot.ts';\nimport { submitToJobber } from './jobber.ts';\nimport { mapSubmission } from './map.ts';\nimport { planCrm } from './plan.ts';\nimport type { CrmConfig, CrmCredentials, CrmResult } from './types.ts';\nimport { PROVIDER_LABELS } from './types.ts';\n\nexport interface CrmDeps {\n credentials?: CrmCredentials | undefined;\n fetch?: typeof fetch | undefined;\n timeoutMs?: number | undefined;\n /**\n * The visitor's page context, provider-neutral. HubSpot reads `hutk` (its\n * own session cookie) to stitch the submission to its tracking; Jobber\n * reads `pageUri` for the note body. Not every provider uses every field —\n * an unused one is simply ignored, which is why this is one shape rather\n * than a per-provider option the caller has to pick correctly.\n */\n pageContext?: HubSpotSubmitContext | undefined;\n}\n\nexport async function deliverToCrm(\n event: ConversionEvent,\n config: CrmConfig | undefined,\n deps: CrmDeps = {},\n): Promise<CrmResult | null> {\n const plan = planCrm(config, deps.credentials);\n if (!plan.enabled || !config || !deps.credentials) {\n // `null` distinguishes \"no CRM configured for this site\" from a failed\n // write — a caller building a notification's delivery notes can choose to\n // say nothing at all rather than \"Not written to (no provider)\".\n return config ? { ok: false, provider: config.provider, reason: plan.skip } : null;\n }\n\n const record = mapSubmission(event, config);\n const fetchImpl = deps.fetch ?? fetch;\n const timeoutMs = deps.timeoutMs ?? 5_000;\n const credentials = deps.credentials;\n\n switch (config.provider) {\n case 'hubspot':\n if (credentials.provider !== 'hubspot') break;\n return submitToHubSpot(\n record,\n config.hubspot!,\n credentials.hubspot,\n deps.pageContext,\n fetchImpl,\n timeoutMs,\n );\n case 'bigin':\n if (credentials.provider !== 'bigin') break;\n return submitToBigin(record, config.bigin!, credentials.bigin, fetchImpl, timeoutMs);\n case 'jobber':\n if (credentials.provider !== 'jobber') break;\n return submitToJobber(\n record,\n config.jobber ?? {},\n credentials.jobber,\n deps.pageContext?.pageUri,\n fetchImpl,\n timeoutMs,\n );\n default: {\n // Exhaustiveness: a provider added to `CrmProvider` with no case above\n // fails the build here, rather than silently falling through to the\n // generic mismatch result below.\n const _exhaustive: never = config.provider;\n return _exhaustive;\n }\n }\n\n return {\n ok: false,\n provider: config.provider,\n reason: plan.skip ?? 'Credentials/provider mismatch.',\n };\n}\n\n/** A one-line, human-readable delivery note — what `conversion.ts` folds into\n * the notification email's \"Delivery\" section. */\nexport function describeCrmResult(result: CrmResult | null): string | null {\n if (!result) return null;\n const name = PROVIDER_LABELS[result.provider];\n if (result.ok) {\n return result.recordId\n ? `Written to ${name} (contact ${result.recordId})`\n : `Written to ${name}`;\n }\n return `⚠ Not written to ${name}: ${result.reason ?? 'unknown reason'}`;\n}\n\nexport { deriveLastName, mapSubmission } from './map.ts';\nexport { planCrm } from './plan.ts';\nexport { submitToBigin, zohoApiHost } from './bigin.ts';\nexport { submitToHubSpot } from './hubspot.ts';\nexport { submitToJobber } from './jobber.ts';\nexport type { HubSpotSubmitContext } from './hubspot.ts';\nexport { clearZohoTokenCache, zohoAccessToken, zohoAccountsHost } from './zoho-token.ts';\nexport { clearJobberTokenCache, jobberAccessToken } from './jobber-token.ts';\nexport type { JobberOAuth } from './jobber-token.ts';\nexport { PROVIDER_LABELS } from './types.ts';\nexport type {\n BiginConfig,\n BiginCredentials,\n BiginDataCenter,\n BiginPipelineConfig,\n CrmConfig,\n CrmCredentials,\n CrmPlan,\n CrmProvider,\n CrmRecord,\n CrmResult,\n HubSpotConfig,\n HubSpotCredentials,\n JobberConfig,\n JobberCredentials,\n} from './types.ts';\n"],"mappings":"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"}
|
|
1
|
+
{"version":3,"file":"index.mjs","names":["EXPIRY_MARGIN_MS","cache"],"sources":["../../src/crm/map.ts","../../src/crm/zoho-token.ts","../../src/crm/bigin.ts","../../src/crm/hubspot.ts","../../src/crm/jobber-token.ts","../../src/crm/jobber.ts","../../src/crm/plan.ts","../../src/crm/types.ts","../../src/crm/index.ts"],"sourcesContent":["/**\n * Turning one submission into a provider-neutral `CrmRecord`.\n *\n * Pure, and separate from both executors for the reason `notify/render.ts` is\n * separate from `email.ts`: shaping the data is where the two subtle rules\n * that must not regress live, and pinning them here means a test can assert\n * them without a network.\n *\n * `fieldMap`/`trackingFields` are drop-if-unmapped, not guess-if-unmapped — an\n * unmapped field silently disappearing from the CRM record is the same shape\n * of decision `describeEvent()` makes for `OMIT_FIELDS`: an absent mapping is\n * a stated design choice (the operator hasn't decided this field matters to\n * the CRM yet), never an error.\n */\nimport { identifySubmitter } from '../tracking/identity.ts';\nimport type { ConversionEvent } from '../tracking/types.ts';\nimport type { CrmConfig, CrmRecord } from './types.ts';\n\n/**\n * Bigin requires `Last_Name`; a form that only asked for a full name or an\n * email needs one derived rather than 400ing on insert.\n *\n * The fallback chain, in order: an explicit last name field → the last space-\n * separated token of a full name → the local part of the email address →\n * refuse, with a stated reason, rather than fabricate a name from nothing.\n * `map.ts` never emits an empty `lastName` for Bigin — `deliverToCrm` reads\n * the refusal and skips the write instead of letting the CRM reject it.\n */\nexport function deriveLastName(identity: {\n firstName?: string | undefined;\n lastName?: string | undefined;\n fullName: string;\n email?: string | undefined;\n}): { lastName: string } | { refusal: string } {\n if (identity.lastName) return { lastName: identity.lastName };\n\n const nameParts = identity.fullName.trim().split(/\\s+/);\n if (nameParts.length > 1) {\n return { lastName: nameParts[nameParts.length - 1]! };\n }\n\n if (identity.email) {\n const local = identity.email.split('@')[0];\n if (local) return { lastName: local };\n }\n\n return {\n refusal:\n 'Could not derive a last name from the submission (no last-name field, ' +\n 'no space in the full name, and no email to fall back to).',\n };\n}\n\nfunction mapExtra(\n formData: Record<string, string>,\n fieldMap: Record<string, string> | undefined,\n): Record<string, string> {\n const out: Record<string, string> = {};\n if (!fieldMap) return out;\n for (const [field, property] of Object.entries(fieldMap)) {\n const value = formData[field];\n if (value) out[property] = value;\n }\n return out;\n}\n\nfunction mapAttribution(\n event: ConversionEvent,\n trackingFields: Record<string, string> | undefined,\n): Record<string, string> {\n const out: Record<string, string> = {};\n // A visitor with no `_ac` cookie — organic arrival, or declined consent —\n // yields `{}`, never an error. Absence is not a mapping failure.\n if (!event.tracking || !trackingFields) return out;\n for (const [key, property] of Object.entries(trackingFields)) {\n const value = event.tracking[key];\n if (value !== undefined && value !== '') out[property] = String(value);\n }\n return out;\n}\n\nexport function mapSubmission(event: ConversionEvent, config: CrmConfig): CrmRecord {\n const formData = event.formData ?? {};\n const identity = identifySubmitter(formData);\n\n return {\n firstName: identity.firstName,\n lastName: identity.lastName,\n email: identity.email,\n phone: identity.phone ?? event.phone,\n company: identity.company,\n message: formData['message'] ?? formData['comments'] ?? formData['comment'],\n extra: mapExtra(formData, config.fieldMap),\n attribution: mapAttribution(event, config.trackingFields),\n };\n}\n","/**\n * Zoho OAuth2 refresh-token exchange, for Bigin.\n *\n * Not `google/token.ts`: that one signs a service-account JWT assertion, which\n * Zoho's grant has no need of. Zoho's self-client grant is a plain\n * `application/x-www-form-urlencoded` POST, so this module is a few lines of\n * fetch and can run on every submission.\n *\n * Cached per isolate, keyed on the refresh token, with an expiry margin — a\n * Worker isolate can field many submissions before it's recycled, and minting\n * a fresh access token on every one would be a needless round trip Bigin's\n * own rate limit does not forgive kindly.\n */\n\nexport interface ZohoDataCenterHosts {\n accounts: string;\n}\n\nconst ZOHO_ACCOUNTS_HOSTS: Record<string, string> = {\n com: 'accounts.zoho.com',\n ca: 'accounts.zohocloud.ca',\n eu: 'accounts.zoho.eu',\n in: 'accounts.zoho.in',\n 'com.au': 'accounts.zoho.com.au',\n jp: 'accounts.zoho.jp',\n};\n\nexport function zohoAccountsHost(dataCenter: string): string {\n return ZOHO_ACCOUNTS_HOSTS[dataCenter] ?? ZOHO_ACCOUNTS_HOSTS.com!;\n}\n\n/** Refresh a few seconds before Zoho actually expires it, so a token that's\n * about to lapse mid-request is never handed out. */\nconst EXPIRY_MARGIN_MS = 60_000;\n\ninterface CachedToken {\n accessToken: string;\n expiresAt: number;\n}\n\nconst cache = new Map<string, CachedToken>();\n\nexport interface ZohoOAuth {\n clientId: string;\n clientSecret: string;\n refreshToken: string;\n}\n\n/**\n * Exchange a refresh token for a short-lived Bigin access token, reusing a\n * cached one while it's still valid.\n *\n * `now` is a parameter so cache expiry is testable without a fake clock, the\n * same convention `google/token.ts`'s `googleAccessToken` uses.\n */\nexport async function zohoAccessToken(\n oauth: ZohoOAuth,\n dataCenter: string,\n fetchImpl: typeof fetch = fetch,\n now: number = Date.now(),\n): Promise<string> {\n const cached = cache.get(oauth.refreshToken);\n if (cached && cached.expiresAt > now) return cached.accessToken;\n\n const body = new URLSearchParams({\n grant_type: 'refresh_token',\n client_id: oauth.clientId,\n client_secret: oauth.clientSecret,\n refresh_token: oauth.refreshToken,\n });\n\n const res = await fetchImpl(`https://${zohoAccountsHost(dataCenter)}/oauth/v2/token`, {\n method: 'POST',\n headers: { 'content-type': 'application/x-www-form-urlencoded' },\n body: body.toString(),\n });\n\n if (!res.ok) {\n throw new Error(`Zoho token exchange failed (${res.status}): ${await res.text()}`);\n }\n\n const json = (await res.json()) as { access_token?: string; expires_in?: number; error?: string };\n if (!json.access_token) {\n throw new Error(\n `Zoho token exchange returned no access_token: ${json.error ?? 'unknown error'}`,\n );\n }\n\n const expiresAt = now + (json.expires_in ?? 3600) * 1000 - EXPIRY_MARGIN_MS;\n cache.set(oauth.refreshToken, { accessToken: json.access_token, expiresAt });\n return json.access_token;\n}\n\n/** Test-only: clear the module-level cache between cases. */\nexport function clearZohoTokenCache(): void {\n cache.clear();\n}\n","/**\n * Zoho Bigin executor — decides nothing except the one thing it must refuse\n * to send: a record with no derivable last name (see `deriveLastName` in\n * `map.ts`). Everything else is \"build the request, post it, classify the\n * response.\"\n *\n * `upsert` with `duplicate_check_fields: ['Email']` (configurable) rather than\n * plain `Contacts` create — a visitor resubmitting the same form (a second\n * enquiry, a retried failed submission) should update one contact, not create\n * a duplicate every time.\n */\nimport { deriveLastName } from './map.ts';\nimport type { BiginConfig, BiginCredentials, CrmRecord, CrmResult } from './types.ts';\nimport { zohoAccessToken } from './zoho-token.ts';\n\nconst ZOHO_API_HOSTS: Record<string, string> = {\n com: 'www.zohoapis.com',\n ca: 'www.zohoapis.ca',\n eu: 'www.zohoapis.eu',\n in: 'www.zohoapis.in',\n 'com.au': 'www.zohoapis.com.au',\n jp: 'www.zohoapis.jp',\n};\n\nexport function zohoApiHost(dataCenter: string): string {\n return ZOHO_API_HOSTS[dataCenter] ?? ZOHO_API_HOSTS.com!;\n}\n\nfunction biginFields(record: CrmRecord, lastName: string): Record<string, unknown> {\n return {\n Last_Name: lastName,\n ...(record.firstName ? { First_Name: record.firstName } : {}),\n ...(record.email ? { Email: record.email } : {}),\n ...(record.phone ? { Phone: record.phone } : {}),\n ...(record.company ? { Account_Name: record.company } : {}),\n ...(record.message ? { Description: record.message } : {}),\n ...record.extra,\n ...record.attribution,\n Lead_Source: 'Website',\n };\n}\n\n/**\n * Write one record to Bigin. Never throws — a token-exchange failure, a\n * network error and a rejected response all come back as `CrmResult`.\n */\nexport async function submitToBigin(\n record: CrmRecord,\n config: BiginConfig,\n credentials: BiginCredentials,\n fetchImpl: typeof fetch = fetch,\n timeoutMs = 5_000,\n): Promise<CrmResult> {\n const derived = deriveLastName({\n firstName: record.firstName,\n lastName: record.lastName,\n fullName: [record.firstName, record.lastName].filter(Boolean).join(' ') || record.email || '',\n email: record.email,\n });\n if ('refusal' in derived) {\n return { ok: false, provider: 'bigin', reason: derived.refusal };\n }\n\n let token: string;\n try {\n token = await zohoAccessToken(credentials, config.dataCenter, fetchImpl);\n } catch (err) {\n return {\n ok: false,\n provider: 'bigin',\n reason: `Zoho token exchange failed: ${err instanceof Error ? err.message : String(err)}`,\n };\n }\n\n const host = zohoApiHost(config.dataCenter);\n const url = `https://${host}/bigin/v2/Contacts/upsert`;\n const body = {\n data: [biginFields(record, derived.lastName)],\n duplicate_check_fields: [config.duplicateCheckField ?? 'Email'],\n trigger: ['workflow'],\n };\n\n let res: Response;\n try {\n res = await fetchImpl(url, {\n method: 'POST',\n headers: {\n 'content-type': 'application/json',\n authorization: `Zoho-oauthtoken ${token}`,\n },\n body: JSON.stringify(body),\n signal: AbortSignal.timeout(timeoutMs),\n });\n } catch (err) {\n return {\n ok: false,\n provider: 'bigin',\n reason: `Bigin request failed: ${err instanceof Error ? err.message : String(err)}`,\n };\n }\n\n const text = await res.text().catch(() => '');\n if (!res.ok) {\n return {\n ok: false,\n provider: 'bigin',\n code: String(res.status),\n reason: `Bigin responded ${res.status}: ${text}`,\n };\n }\n\n let json: { data?: { code?: string; status?: string; details?: { id?: string } }[] };\n try {\n json = JSON.parse(text);\n } catch {\n return {\n ok: false,\n provider: 'bigin',\n reason: `Bigin returned an unparsable response: ${text}`,\n };\n }\n\n const entry = json.data?.[0];\n if (!entry || entry.status !== 'success') {\n return {\n ok: false,\n provider: 'bigin',\n code: entry?.code,\n reason: `Bigin upsert did not succeed: ${text}`,\n };\n }\n\n const contactId = entry.details?.id;\n\n // The pipeline write is additive and best-effort: the contact already\n // exists, so a failure here is a lost pipeline card, not a lost lead. It\n // never downgrades the overall `ok: true` the contact write already earned.\n if (config.pipeline?.enabled && contactId) {\n await createPipelineRecord(record, config, contactId, host, token, fetchImpl, timeoutMs).catch(\n () => undefined,\n );\n }\n\n return { ok: true, provider: 'bigin', recordId: contactId };\n}\n\nasync function createPipelineRecord(\n record: CrmRecord,\n config: BiginConfig,\n contactId: string,\n host: string,\n token: string,\n fetchImpl: typeof fetch,\n timeoutMs: number,\n): Promise<void> {\n const pipeline = config.pipeline!;\n const body = {\n data: [\n {\n Pipeline_Name: record.company || record.email || 'Website enquiry',\n Contact_Name: contactId,\n ...(pipeline.subPipeline ? { Sub_Pipeline: pipeline.subPipeline } : {}),\n ...(pipeline.stage ? { Stage: pipeline.stage } : {}),\n },\n ],\n trigger: ['workflow'],\n };\n await fetchImpl(`https://${host}/bigin/v2/Pipelines`, {\n method: 'POST',\n headers: {\n 'content-type': 'application/json',\n authorization: `Zoho-oauthtoken ${token}`,\n },\n body: JSON.stringify(body),\n signal: AbortSignal.timeout(timeoutMs),\n });\n}\n","/**\n * HubSpot executor — decides nothing, posts what it's told.\n *\n * Uses the **secure** submissions endpoint (`/submissions/v3/integration/\n * secure/submit/{portalId}/{formGuid}`, private-app Bearer token) rather than\n * the public forms-embed endpoint. That distinction is load-bearing: posting\n * to the secure endpoint registers a real *form submission* against the\n * portal's form, so HubSpot's own workflows, lifecycle-stage automation and\n * attribution reporting fire exactly as if the client's hosted form had been\n * used — the public endpoint has no such effect and is also the one that\n * rejects a field not already declared on the form (see `CrmRecord`'s\n * drop-if-unmapped rule, which exists partly because of that).\n */\nimport type { CrmRecord, CrmResult, HubSpotConfig, HubSpotCredentials } from './types.ts';\n\nconst HUBSPOT_HOSTS: Record<NonNullable<HubSpotConfig['dataCenter']>, string> = {\n na: 'api.hsforms.com',\n eu: 'api-eu1.hsforms.com',\n};\n\ninterface HubSpotField {\n objectTypeId: '0-1';\n name: string;\n value: string;\n}\n\nfunction fieldsFor(record: CrmRecord): HubSpotField[] {\n const fields: HubSpotField[] = [];\n const push = (name: string, value: string | undefined) => {\n if (value) fields.push({ objectTypeId: '0-1', name, value });\n };\n push('firstname', record.firstName);\n push('lastname', record.lastName);\n push('email', record.email);\n push('phone', record.phone);\n push('company', record.company);\n push('message', record.message);\n for (const [name, value] of Object.entries(record.extra)) push(name, value);\n for (const [name, value] of Object.entries(record.attribution)) push(name, value);\n return fields;\n}\n\nexport interface HubSpotSubmitContext {\n hutk?: string | undefined;\n pageUri?: string | undefined;\n pageName?: string | undefined;\n}\n\n/**\n * Submit one record to HubSpot's secure forms endpoint.\n *\n * Never throws — a network failure or an unparsable response both come back\n * as a `CrmResult` with `ok: false`, the same \"structured result, not an\n * exception\" rule `security/turnstile.ts` and `indexing/gsc.ts` follow. Status\n * classification: 401 is a rejected/expired token; 400 naming\n * `MISSING_REQUIRED_FIELDS` or an unrecognised field name is a mapping\n * problem (`fieldMap`/`trackingFields` naming a property that isn't on the\n * form); 429 is the per-portal rate limit.\n */\nexport async function submitToHubSpot(\n record: CrmRecord,\n config: HubSpotConfig,\n credentials: HubSpotCredentials,\n context: HubSpotSubmitContext = {},\n fetchImpl: typeof fetch = fetch,\n timeoutMs = 5_000,\n): Promise<CrmResult> {\n const host = HUBSPOT_HOSTS[config.dataCenter ?? 'na'];\n const url = `https://${host}/submissions/v3/integration/secure/submit/${config.portalId}/${config.formGuid}`;\n\n const body = {\n fields: fieldsFor(record),\n context: {\n ...(context.hutk ? { hutk: context.hutk } : {}),\n ...(context.pageUri ? { pageUri: context.pageUri } : {}),\n ...(context.pageName ? { pageName: context.pageName } : {}),\n },\n };\n\n let res: Response;\n try {\n res = await fetchImpl(url, {\n method: 'POST',\n headers: {\n 'content-type': 'application/json',\n authorization: `Bearer ${credentials.accessToken}`,\n },\n body: JSON.stringify(body),\n signal: AbortSignal.timeout(timeoutMs),\n });\n } catch (err) {\n return {\n ok: false,\n provider: 'hubspot',\n reason: `HubSpot request failed: ${err instanceof Error ? err.message : String(err)}`,\n };\n }\n\n if (res.ok) {\n return { ok: true, provider: 'hubspot' };\n }\n\n const text = await res.text().catch(() => '');\n\n if (res.status === 401) {\n return {\n ok: false,\n provider: 'hubspot',\n code: '401',\n reason: `HubSpot rejected the access token: ${text}`,\n };\n }\n if (res.status === 429) {\n return {\n ok: false,\n provider: 'hubspot',\n code: '429',\n reason: 'HubSpot rate limit exceeded for this portal.',\n };\n }\n if (\n res.status === 400 &&\n /MISSING_REQUIRED_FIELDS|not.*(a|an).*(valid|known).*field/i.test(text)\n ) {\n return {\n ok: false,\n provider: 'hubspot',\n code: '400',\n reason: `HubSpot rejected the mapped fields — check \\`fieldMap\\`/\\`trackingFields\\` against the form's own fields: ${text}`,\n };\n }\n\n return {\n ok: false,\n provider: 'hubspot',\n code: String(res.status),\n reason: `HubSpot responded ${res.status}: ${text}`,\n };\n}\n","/**\n * Jobber OAuth2 refresh-token exchange.\n *\n * A plain `application/x-www-form-urlencoded` POST, same shape as Zoho's —\n * see `zoho-token.ts` for why that keeps this Worker-safe (no `node:crypto`,\n * unlike `google/token.ts`'s service-account JWT).\n *\n * One thing Zoho's grant never does: Jobber's Refresh Token Rotation is ON by\n * default for a new app, and when it's on, every exchange returns a *new*\n * refresh token and invalidates the old one. A Worker reading a static env\n * var (`VITOPS_JOBBER_REFRESH_TOKEN`) cannot persist that new value, so this\n * integration requires rotation turned OFF in the app's Developer Center\n * settings — a legitimate setup requirement, since a per-client private app\n * is never published to Jobber's Marketplace (where rotation ON is\n * mandatory). `zohoAccessToken`'s Zoho equivalent has no such failure mode to\n * detect, because Zoho's grant is long-lived by default.\n *\n * Cached per isolate, keyed on the refresh token, with an expiry margin —\n * same rationale as `zoho-token.ts`.\n */\n\nconst JOBBER_OAUTH_TOKEN_URL = 'https://api.getjobber.com/api/oauth/token';\n\n/** Refresh a few seconds before Jobber actually expires it (access tokens\n * last 60 minutes), so a token that's about to lapse mid-request is never\n * handed out. */\nconst EXPIRY_MARGIN_MS = 60_000;\n\ninterface CachedToken {\n accessToken: string;\n expiresAt: number;\n}\n\nconst cache = new Map<string, CachedToken>();\n\nexport interface JobberOAuth {\n clientId: string;\n clientSecret: string;\n refreshToken: string;\n}\n\n/**\n * Exchange a refresh token for a short-lived Jobber access token, reusing a\n * cached one while it's still valid.\n *\n * Throws a specific, actionable error when Jobber's response carries a\n * *different* refresh token than the one sent — the rotation case above.\n * Without this check, rotation surfaces an hour later as an opaque 401 on a\n * lead that is already lost; with it, the first submission after rotation\n * turns on names the fix.\n *\n * `now` is a parameter so cache expiry is testable without a fake clock, the\n * same convention `zoho-token.ts`'s `zohoAccessToken` uses.\n */\nexport async function jobberAccessToken(\n oauth: JobberOAuth,\n fetchImpl: typeof fetch = fetch,\n now: number = Date.now(),\n): Promise<string> {\n const cached = cache.get(oauth.refreshToken);\n if (cached && cached.expiresAt > now) return cached.accessToken;\n\n const body = new URLSearchParams({\n grant_type: 'refresh_token',\n client_id: oauth.clientId,\n client_secret: oauth.clientSecret,\n refresh_token: oauth.refreshToken,\n });\n\n const res = await fetchImpl(JOBBER_OAUTH_TOKEN_URL, {\n method: 'POST',\n headers: { 'content-type': 'application/x-www-form-urlencoded' },\n body: body.toString(),\n });\n\n if (!res.ok) {\n throw new Error(`Jobber token exchange failed (${res.status}): ${await res.text()}`);\n }\n\n const json = (await res.json()) as {\n access_token?: string;\n refresh_token?: string;\n expires_in?: number;\n error?: string;\n };\n if (!json.access_token) {\n throw new Error(\n `Jobber token exchange returned no access_token: ${json.error ?? 'unknown error'}`,\n );\n }\n if (json.refresh_token && json.refresh_token !== oauth.refreshToken) {\n throw new Error(\n 'Jobber returned a rotated refresh token. Turn Refresh Token Rotation off for this app ' +\n \"in Jobber's Developer Center — a rotated token cannot be persisted from a stateless \" +\n 'Worker reading a static VITOPS_JOBBER_REFRESH_TOKEN.',\n );\n }\n\n const expiresAt = now + (json.expires_in ?? 3600) * 1000 - EXPIRY_MARGIN_MS;\n cache.set(oauth.refreshToken, { accessToken: json.access_token, expiresAt });\n return json.access_token;\n}\n\n/** Test-only: clear the module-level cache between cases. */\nexport function clearJobberTokenCache(): void {\n cache.clear();\n}\n","/**\n * Jobber executor — a GraphQL API, unlike both existing REST executors\n * (`hubspot.ts`, `bigin.ts`), and that difference is why this file classifies\n * differently from either of them.\n *\n * **GraphQL answers `200 OK` for failures.** A malformed query or a\n * permission/version problem returns a top-level `errors[]`; a rejected\n * mutation input returns `data.clientCreate.userErrors[]` — both with HTTP\n * 200. So classification here is four layers, in order: transport error →\n * HTTP status (401 credential, 429 rate limit) → top-level `errors[]` (a\n * query/permission/version problem) → `userErrors[]` (a mapping problem: an\n * unknown field, a missing required attribute). Only past all four is\n * `ok: true`.\n *\n * **Only `clientCreate` is verified against Jobber's own published docs.**\n * Two shapes here are NOT: `noteCreate` (Jobber's changelog records\n * `requestNoteCreate`/`requestUpsert` being *removed*, so the note surface is\n * genuinely in flux) and the `clients` query's dedupe filter argument. Until\n * both are checked against a live account's GraphiQL schema:\n * - the note write is wrapped in `.catch(() => undefined)`, exactly like\n * Bigin's `createPipelineRecord` — a failure here loses the note, never\n * the already-written client, and never downgrades `ok: true`;\n * - `dedupe` defaults to `false` rather than the eventually-intended `true`,\n * so a misshapen filter never silently blocks a real submission. Turning\n * it on is safe once the query is confirmed; document the confirmed\n * shape in this docblock when that happens.\n *\n * Attribution (`record.attribution`) and unmapped extras (`record.extra`)\n * are rendered into the note body by default — that works on every Jobber\n * plan. `config.jobber` carries no separate custom-field wiring in this\n * pass: Jobber's app-configured custom fields need the Connect plan or\n * higher and their `clientCreate` input shape is a further unverified\n * surface, so `fieldMap`/`trackingFields` values still land as note lines\n * rather than a guessed custom-field mutation input.\n */\nimport type { CrmRecord, CrmResult, JobberConfig, JobberCredentials } from './types.ts';\nimport { jobberAccessToken } from './jobber-token.ts';\n\nconst JOBBER_GRAPHQL_ENDPOINT = 'https://api.getjobber.com/api/graphql';\n\n/** Pinned to the version this integration was built and tested against.\n * Jobber's `X-JOBBER-GRAPHQL-VERSION` header is mandatory — an unversioned\n * request is rejected outright — so this is code, not something left to\n * config to supply by default. `config.jobber.apiVersion` is the escape\n * hatch for a client's app pinned to a different one. */\nconst JOBBER_API_VERSION = '2025-04-16';\n\ninterface JobberErrorItem {\n message: string;\n}\n\ninterface ClientCreateResponse {\n data?: {\n clientCreate?: {\n client?: { id: string } | null;\n userErrors?: JobberErrorItem[];\n } | null;\n };\n errors?: JobberErrorItem[];\n}\n\nfunction clientCreateInput(record: CrmRecord): Record<string, unknown> {\n return {\n ...(record.firstName ? { firstName: record.firstName } : {}),\n ...(record.lastName ? { lastName: record.lastName } : {}),\n ...(record.company ? { companyName: record.company } : {}),\n ...(record.email\n ? { emails: [{ description: 'MAIN', primary: true, address: record.email }] }\n : {}),\n ...(record.phone\n ? { phones: [{ description: 'MAIN', primary: true, number: record.phone }] }\n : {}),\n };\n}\n\n/** Note body: the submitted message plus every attribution/extra field that\n * has no dedicated Jobber field, so nothing is lost to the note-vs-field\n * split above. Empty sections are omitted rather than rendered blank. */\nfunction noteBody(record: CrmRecord, pageUri: string | undefined): string {\n const lines: string[] = [];\n if (record.message) lines.push(record.message);\n const extraEntries = Object.entries(record.extra);\n if (extraEntries.length) {\n lines.push('', ...extraEntries.map(([k, v]) => `${k}: ${v}`));\n }\n const attributionEntries = Object.entries(record.attribution);\n if (attributionEntries.length) {\n lines.push('', 'Attribution:', ...attributionEntries.map(([k, v]) => `${k}: ${v}`));\n }\n if (pageUri) lines.push('', `Submitted from ${pageUri}`);\n return lines.join('\\n').trim();\n}\n\nasync function graphql(\n query: string,\n variables: Record<string, unknown>,\n token: string,\n apiVersion: string,\n fetchImpl: typeof fetch,\n timeoutMs: number,\n): Promise<Response> {\n return fetchImpl(JOBBER_GRAPHQL_ENDPOINT, {\n method: 'POST',\n headers: {\n 'content-type': 'application/json',\n authorization: `Bearer ${token}`,\n 'x-jobber-graphql-version': apiVersion,\n },\n body: JSON.stringify({ query, variables }),\n signal: AbortSignal.timeout(timeoutMs),\n });\n}\n\nconst CLIENT_CREATE_MUTATION = `\n mutation CrmClientCreate($input: ClientCreateInput!) {\n clientCreate(input: $input) {\n client { id }\n userErrors { message }\n }\n }\n`;\n\n/** Best-effort — see the module docblock's note on the unverified\n * `noteCreate` shape. Any failure (transport, GraphQL error, or the mutation\n * simply not existing under this name) is swallowed by the caller. */\nconst NOTE_CREATE_MUTATION = `\n mutation CrmNoteCreate($input: NoteCreateInput!) {\n noteCreate(input: $input) {\n note { id }\n userErrors { message }\n }\n }\n`;\n\nasync function createNote(\n clientId: string,\n message: string,\n token: string,\n apiVersion: string,\n fetchImpl: typeof fetch,\n timeoutMs: number,\n): Promise<void> {\n if (!message) return;\n const res = await graphql(\n NOTE_CREATE_MUTATION,\n { input: { clientId, message } },\n token,\n apiVersion,\n fetchImpl,\n timeoutMs,\n );\n const json = (await res.json()) as {\n errors?: JobberErrorItem[];\n data?: { noteCreate?: { userErrors?: JobberErrorItem[] } };\n };\n if (json.errors?.length) throw new Error(json.errors.map((e) => e.message).join('; '));\n const userErrors = json.data?.noteCreate?.userErrors;\n if (userErrors?.length) throw new Error(userErrors.map((e) => e.message).join('; '));\n}\n\n/** Best-effort — see the module docblock's note on the unverified `clients`\n * filter shape. A failure (or the field simply not matching this account's\n * schema) means \"could not dedupe\", not \"stop the submission\" — falls\n * through to creating a new client. */\nconst FIND_CLIENT_BY_EMAIL_QUERY = `\n query CrmFindClientByEmail($searchTerm: String!) {\n clients(first: 1, searchTerm: $searchTerm) {\n nodes { id }\n }\n }\n`;\n\nasync function findExistingClientId(\n email: string,\n token: string,\n apiVersion: string,\n fetchImpl: typeof fetch,\n timeoutMs: number,\n): Promise<string | undefined> {\n const res = await graphql(\n FIND_CLIENT_BY_EMAIL_QUERY,\n { searchTerm: email },\n token,\n apiVersion,\n fetchImpl,\n timeoutMs,\n );\n const json = (await res.json()) as {\n errors?: JobberErrorItem[];\n data?: { clients?: { nodes?: { id: string }[] } };\n };\n if (json.errors?.length) throw new Error(json.errors.map((e) => e.message).join('; '));\n return json.data?.clients?.nodes?.[0]?.id;\n}\n\n/**\n * Write one record to Jobber. Never throws — a token-exchange failure, a\n * network error, a top-level GraphQL `errors[]`, and a `userErrors[]` all\n * come back as a `CrmResult` with `ok: false`.\n */\nexport async function submitToJobber(\n record: CrmRecord,\n config: JobberConfig,\n credentials: JobberCredentials,\n pageUri: string | undefined,\n fetchImpl: typeof fetch = fetch,\n timeoutMs = 5_000,\n): Promise<CrmResult> {\n const apiVersion = config.apiVersion ?? JOBBER_API_VERSION;\n\n let token: string;\n try {\n token = await jobberAccessToken(credentials, fetchImpl);\n } catch (err) {\n return {\n ok: false,\n provider: 'jobber',\n reason: `Jobber token exchange failed: ${err instanceof Error ? err.message : String(err)}`,\n };\n }\n\n // Dedupe defaults to false until the `clients` filter shape above is\n // verified against a live schema — see the module docblock.\n if (config.dedupe === true && record.email) {\n try {\n const existingId = await findExistingClientId(\n record.email,\n token,\n apiVersion,\n fetchImpl,\n timeoutMs,\n );\n if (existingId) {\n if (config.note?.enabled !== false) {\n await createNote(\n existingId,\n noteBody(record, pageUri),\n token,\n apiVersion,\n fetchImpl,\n timeoutMs,\n ).catch(() => undefined);\n }\n return { ok: true, provider: 'jobber', recordId: existingId };\n }\n } catch {\n // Could not dedupe — proceed to create rather than fail the submission.\n }\n }\n\n let res: Response;\n try {\n res = await graphql(\n CLIENT_CREATE_MUTATION,\n { input: clientCreateInput(record) },\n token,\n apiVersion,\n fetchImpl,\n timeoutMs,\n );\n } catch (err) {\n return {\n ok: false,\n provider: 'jobber',\n reason: `Jobber request failed: ${err instanceof Error ? err.message : String(err)}`,\n };\n }\n\n if (res.status === 401) {\n return {\n ok: false,\n provider: 'jobber',\n code: '401',\n reason: `Jobber rejected the access token: ${await res.text().catch(() => '')}`,\n };\n }\n if (res.status === 429) {\n return {\n ok: false,\n provider: 'jobber',\n code: '429',\n reason: 'Jobber rate limit exceeded (2,500 requests per 5 minutes).',\n };\n }\n if (!res.ok) {\n return {\n ok: false,\n provider: 'jobber',\n code: String(res.status),\n reason: `Jobber responded ${res.status}: ${await res.text().catch(() => '')}`,\n };\n }\n\n let json: ClientCreateResponse;\n try {\n json = (await res.json()) as ClientCreateResponse;\n } catch {\n return { ok: false, provider: 'jobber', reason: 'Jobber returned an unparsable response.' };\n }\n\n if (json.errors?.length) {\n return {\n ok: false,\n provider: 'jobber',\n reason: `Jobber rejected the request: ${json.errors.map((e) => e.message).join('; ')}`,\n };\n }\n\n const userErrors = json.data?.clientCreate?.userErrors;\n if (userErrors?.length) {\n return {\n ok: false,\n provider: 'jobber',\n reason: `Jobber rejected the mapped fields: ${userErrors.map((e) => e.message).join('; ')}`,\n };\n }\n\n const clientId = json.data?.clientCreate?.client?.id;\n if (!clientId) {\n return { ok: false, provider: 'jobber', reason: 'Jobber did not return a client id.' };\n }\n\n if (config.note?.enabled !== false) {\n await createNote(\n clientId,\n noteBody(record, pageUri),\n token,\n apiVersion,\n fetchImpl,\n timeoutMs,\n ).catch(() => undefined);\n }\n\n return { ok: true, provider: 'jobber', recordId: clientId };\n}\n","/**\n * Every decision about *whether* a submission reaches a CRM, as one pure\n * function.\n *\n * Pure in the load-bearing sense: no `fetch`, no clock, no credential lookup\n * beyond what the caller already resolved. Mirrors `notify/plan.ts`'s\n * `ChannelPlan` shape — `enabled`, or `false` with a stated `skip` — so `--dry`\n * is a complete account and a misconfigured site can be told exactly why\n * nothing will be written, rather than discovering it as a silently missing\n * contact.\n *\n * What this file does NOT decide: whether *this* submission's write succeeds.\n * That is `map.ts` (shaping the data) and the executors (`hubspot.ts`/\n * `bigin.ts`/`jobber.ts`, making the call) — this only decides whether the\n * channel is on at all, from config and credentials.\n */\nimport type { CrmConfig, CrmCredentials, CrmPlan, CrmProvider } from './types.ts';\n\n/**\n * What each provider requires to be considered configured, as one table\n * rather than a per-provider `if`. A provider missing here is a type error —\n * `Record<CrmProvider, …>` — rather than a silently-enabled channel with no\n * config block, which is what the equivalent hand-written checks would do for\n * a provider nobody added a branch for.\n */\nconst PROVIDER_REQUIREMENTS: Record<\n CrmProvider,\n { block: 'hubspot' | 'bigin' | 'jobber'; credentialHint: string }\n> = {\n hubspot: { block: 'hubspot', credentialHint: 'No HubSpot access token configured.' },\n bigin: { block: 'bigin', credentialHint: 'No Zoho OAuth credentials configured.' },\n jobber: { block: 'jobber', credentialHint: 'No Jobber OAuth credentials configured.' },\n};\n\nexport function planCrm(\n config: CrmConfig | undefined,\n credentials: CrmCredentials | undefined,\n): CrmPlan {\n if (!config) {\n return { enabled: false, skip: 'No `crm` configured.' };\n }\n\n if (config.enabled === false) {\n return { enabled: false, provider: config.provider, skip: '`crm.enabled` is false.' };\n }\n\n const requirement = PROVIDER_REQUIREMENTS[config.provider];\n if (!config[requirement.block]) {\n return {\n enabled: false,\n provider: config.provider,\n skip: `\\`crm.provider\\` is \"${config.provider}\" but \\`crm.${requirement.block}\\` is missing.`,\n };\n }\n\n if (!credentials) {\n return {\n enabled: false,\n provider: config.provider,\n skip: requirement.credentialHint,\n };\n }\n\n if (credentials.provider !== config.provider) {\n return {\n enabled: false,\n provider: config.provider,\n skip: `\\`crm.provider\\` is \"${config.provider}\" but the supplied credentials are for \"${credentials.provider}\".`,\n };\n }\n\n return { enabled: true, provider: config.provider };\n}\n","/**\n * The CRM module's own option surface.\n *\n * The config shapes are NOT declared here. They are inferred from the schema in\n * `@getvitops/config` — the same one `validateConfig` runs — so the type a CRM executor\n * accepts and the document the validator accepts cannot drift. This module used to keep a\n * field-for-field copy, because the schema lived in a package above this one; moving the\n * provider blocks into a leaf package is what removed the copy, the adapter that converted\n * into it, and the test that held the two together.\n */\nimport type {\n BiginConfig,\n BiginPipelineConfig,\n CrmConfig,\n HubSpotConfig,\n JobberConfig,\n} from '@getvitops/config';\nimport type { BIGIN_DATA_CENTERS, CrmProviderConfig } from '@getvitops/config/providers';\n\nexport type { BiginConfig, BiginPipelineConfig, CrmConfig, HubSpotConfig, JobberConfig };\n\nexport type CrmProvider = CrmProviderConfig;\n\n/** Human-readable label per provider, for the notification email's Delivery\n * line (`describeCrmResult`) and anywhere else a provider must be named\n * without a hand-written ternary. Exhaustive by type — a provider missing\n * here is a compile error, not a mislabelled email. */\nexport const PROVIDER_LABELS: Record<CrmProvider, string> = {\n hubspot: 'HubSpot',\n bigin: 'Bigin',\n jobber: 'Jobber',\n};\n\n/** Bigin's data centre — the region a self-client's tokens and API host belong to. */\nexport type BiginDataCenter = (typeof BIGIN_DATA_CENTERS)[number];\n\n/** Credentials, never config — always injected by the caller from env vars. */\nexport interface HubSpotCredentials {\n accessToken: string;\n}\n\nexport interface BiginCredentials {\n clientId: string;\n clientSecret: string;\n refreshToken: string;\n}\n\n/**\n * Jobber's self-client OAuth credential. The refresh token is expected to be\n * static — Refresh Token Rotation must be OFF in the app's Developer Center\n * settings, since a Worker reading a static env var cannot persist a rotated\n * one. See `jobber-token.ts` for the detection when a client's app has it on\n * anyway.\n */\nexport interface JobberCredentials {\n clientId: string;\n clientSecret: string;\n refreshToken: string;\n}\n\nexport type CrmCredentials =\n | { provider: 'hubspot'; hubspot: HubSpotCredentials }\n | { provider: 'bigin'; bigin: BiginCredentials }\n | { provider: 'jobber'; jobber: JobberCredentials };\n\n/** A channel's decision: on, or off with a stated reason. Mirrors `ChannelPlan`. */\nexport interface CrmPlan {\n enabled: boolean;\n provider?: CrmProvider | undefined;\n /** Why it's off. Present iff `enabled` is false. */\n skip?: string | undefined;\n}\n\n/** A provider-neutral record — what a submission maps to, before either executor\n * renders it into that CRM's own request shape. */\nexport interface CrmRecord {\n firstName?: string | undefined;\n lastName?: string | undefined;\n email?: string | undefined;\n phone?: string | undefined;\n company?: string | undefined;\n message?: string | undefined;\n /** Mapped fields beyond the identity ones above, keyed by the CRM property name. */\n extra: Record<string, string>;\n /** `trackingFields`-mapped attribution, keyed by the CRM property name. Empty\n * when the visitor arrived with no `_ac` cookie — never an error. */\n attribution: Record<string, string>;\n}\n\n/**\n * The outcome of one delivery attempt. Never thrown — a CRM write is\n * best-effort and its failure is a fact for the caller to log or notify with,\n * not an exception to propagate into the request that produced the submission.\n */\nexport interface CrmResult {\n ok: boolean;\n provider: CrmProvider;\n /** The created/updated record's id, when the CRM returned one. */\n recordId?: string | undefined;\n /** Why it didn't write, or what the classification of the failure was. */\n reason?: string | undefined;\n /** The provider's own error/status code, when it gave one. */\n code?: string | undefined;\n}\n\n/** What `mapSubmission` needs, gathered by the caller once per site. */\nexport interface CrmMapContext {\n config: CrmConfig;\n}\n","/**\n * CRM form-submission delivery — plan, map, dispatch.\n *\n * The seam between \"a form was submitted\" and \"there's a contact in the\n * client's CRM for it.\" A `ConversionEvent` from `@getvitops/utils/tracking`\n * goes in — the same event `notify` already turns into an email — and where\n * it goes and how it's shaped are config, not code at the call site.\n *\n * Pure-planner / executor split, matching `notify/`, `indexing/`, `ads/`:\n * - `plan.ts` decides whether the channel is on at all, purely.\n * - `map.ts` decides how one submission becomes a `CrmRecord`, purely.\n * - `hubspot.ts` / `bigin.ts` / `jobber.ts` execute, and decide nothing\n * beyond the one thing Bigin must refuse (see `deriveLastName`) and the\n * four-layer classification `jobber.ts`'s GraphQL transport needs.\n *\n * `deliverToCrm` never throws: a CRM outage must not lose a lead, so the\n * caller (`createConversionRoute`) always gets a `CrmResult` back to log or\n * fold into the notification email, never an exception to catch.\n *\n * TODO — no generic connector framework. Three CRMs with three genuinely\n * different transports (a form-submission POST, a REST upsert, a GraphQL\n * mutation) is evidence against a shared abstraction, not for one — matching\n * `notify`'s own restraint about its unimplemented channels.\n */\nimport type { ConversionEvent } from '../tracking/types.ts';\nimport { submitToBigin } from './bigin.ts';\nimport { submitToHubSpot, type HubSpotSubmitContext } from './hubspot.ts';\nimport { submitToJobber } from './jobber.ts';\nimport { mapSubmission } from './map.ts';\nimport { planCrm } from './plan.ts';\nimport type { CrmConfig, CrmCredentials, CrmResult } from './types.ts';\nimport { PROVIDER_LABELS } from './types.ts';\n\nexport interface CrmDeps {\n credentials?: CrmCredentials | undefined;\n fetch?: typeof fetch | undefined;\n timeoutMs?: number | undefined;\n /**\n * The visitor's page context, provider-neutral. HubSpot reads `hutk` (its\n * own session cookie) to stitch the submission to its tracking; Jobber\n * reads `pageUri` for the note body. Not every provider uses every field —\n * an unused one is simply ignored, which is why this is one shape rather\n * than a per-provider option the caller has to pick correctly.\n */\n pageContext?: HubSpotSubmitContext | undefined;\n}\n\nexport async function deliverToCrm(\n event: ConversionEvent,\n config: CrmConfig | undefined,\n deps: CrmDeps = {},\n): Promise<CrmResult | null> {\n const plan = planCrm(config, deps.credentials);\n if (!plan.enabled || !config || !deps.credentials) {\n // `null` distinguishes \"no CRM configured for this site\" from a failed\n // write — a caller building a notification's delivery notes can choose to\n // say nothing at all rather than \"Not written to (no provider)\".\n return config ? { ok: false, provider: config.provider, reason: plan.skip } : null;\n }\n\n const record = mapSubmission(event, config);\n const fetchImpl = deps.fetch ?? fetch;\n const timeoutMs = deps.timeoutMs ?? 5_000;\n const credentials = deps.credentials;\n\n switch (config.provider) {\n case 'hubspot':\n if (credentials.provider !== 'hubspot') break;\n return submitToHubSpot(\n record,\n config.hubspot!,\n credentials.hubspot,\n deps.pageContext,\n fetchImpl,\n timeoutMs,\n );\n case 'bigin':\n if (credentials.provider !== 'bigin') break;\n return submitToBigin(record, config.bigin!, credentials.bigin, fetchImpl, timeoutMs);\n case 'jobber':\n if (credentials.provider !== 'jobber') break;\n return submitToJobber(\n record,\n config.jobber ?? {},\n credentials.jobber,\n deps.pageContext?.pageUri,\n fetchImpl,\n timeoutMs,\n );\n default: {\n // Exhaustiveness: a provider added to `CrmProvider` with no case above\n // fails the build here, rather than silently falling through to the\n // generic mismatch result below.\n const _exhaustive: never = config.provider;\n return _exhaustive;\n }\n }\n\n return {\n ok: false,\n provider: config.provider,\n reason: plan.skip ?? 'Credentials/provider mismatch.',\n };\n}\n\n/** A one-line, human-readable delivery note — what `conversion.ts` folds into\n * the notification email's \"Delivery\" section. */\nexport function describeCrmResult(result: CrmResult | null): string | null {\n if (!result) return null;\n const name = PROVIDER_LABELS[result.provider];\n if (result.ok) {\n return result.recordId\n ? `Written to ${name} (contact ${result.recordId})`\n : `Written to ${name}`;\n }\n return `⚠ Not written to ${name}: ${result.reason ?? 'unknown reason'}`;\n}\n\nexport { deriveLastName, mapSubmission } from './map.ts';\nexport { planCrm } from './plan.ts';\nexport { submitToBigin, zohoApiHost } from './bigin.ts';\nexport { submitToHubSpot } from './hubspot.ts';\nexport { submitToJobber } from './jobber.ts';\nexport type { HubSpotSubmitContext } from './hubspot.ts';\nexport { clearZohoTokenCache, zohoAccessToken, zohoAccountsHost } from './zoho-token.ts';\nexport { clearJobberTokenCache, jobberAccessToken } from './jobber-token.ts';\nexport type { JobberOAuth } from './jobber-token.ts';\nexport { PROVIDER_LABELS } from './types.ts';\nexport type {\n BiginConfig,\n BiginCredentials,\n BiginDataCenter,\n BiginPipelineConfig,\n CrmConfig,\n CrmCredentials,\n CrmPlan,\n CrmProvider,\n CrmRecord,\n CrmResult,\n HubSpotConfig,\n HubSpotCredentials,\n JobberConfig,\n JobberCredentials,\n} from './types.ts';\n"],"mappings":"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"}
|
package/dist/email/index.d.mts
CHANGED
|
@@ -1,5 +1,4 @@
|
|
|
1
|
-
import { o as backoffSchedule } from "../plan-
|
|
2
|
-
//#region src/email/types.d.ts
|
|
1
|
+
import { o as backoffSchedule } from "../plan-CH3Wt5ZV.mjs";
|
|
3
2
|
/**
|
|
4
3
|
* The email-onboarding module's own option surface.
|
|
5
4
|
*
|
|
@@ -60,8 +59,6 @@ interface ZoneSendingState {
|
|
|
60
59
|
zoneId?: string | undefined;
|
|
61
60
|
subdomains: SendingDomainState[];
|
|
62
61
|
}
|
|
63
|
-
//#endregion
|
|
64
|
-
//#region src/email/plan.d.ts
|
|
65
62
|
type StepAction = 'create' | 'skip' | 'blocked';
|
|
66
63
|
interface StepPlan {
|
|
67
64
|
action: StepAction;
|
|
@@ -105,8 +102,6 @@ export declare function dnsStatus(expected: {
|
|
|
105
102
|
record: (typeof expected)[number];
|
|
106
103
|
present: boolean;
|
|
107
104
|
}[];
|
|
108
|
-
//#endregion
|
|
109
|
-
//#region src/email/cloudflare.d.ts
|
|
110
105
|
/**
|
|
111
106
|
* Find the zone a host belongs to, walking labels from most to least specific —
|
|
112
107
|
* the same walk `wrangler`'s own `resolveDomain` does, because a sending name is
|
|
@@ -158,16 +153,12 @@ export declare function sendingDns(token: string, zoneId: string, subdomainId: s
|
|
|
158
153
|
}[];
|
|
159
154
|
message?: string;
|
|
160
155
|
}>;
|
|
161
|
-
//#endregion
|
|
162
|
-
//#region src/email/dns.d.ts
|
|
163
156
|
/** Resolve TXT records for a name over DoH. `NXDOMAIN`/no-answer both return `[]`. */
|
|
164
157
|
export declare function resolveTxt(name: string, fetchImpl?: typeof fetch): Promise<{
|
|
165
158
|
ok: boolean;
|
|
166
159
|
contents: string[];
|
|
167
160
|
message?: string;
|
|
168
161
|
}>;
|
|
169
|
-
//#endregion
|
|
170
|
-
//#region src/email/scan.d.ts
|
|
171
162
|
/**
|
|
172
163
|
* Best-effort static scan for `from` addresses in consumer source.
|
|
173
164
|
*
|
|
@@ -211,6 +202,5 @@ export declare function collectFromAddresses(files: readonly {
|
|
|
211
202
|
path: string;
|
|
212
203
|
text: string;
|
|
213
204
|
}[]): FromScanResult;
|
|
214
|
-
//#endregion
|
|
215
205
|
export { type DomainResult, type DynamicFromRef, type EmailConfig, type EmailPlan, type EmailProvider, type ExpectedRecord, type FromCandidate, type FromRef, type FromScanResult, type SendingDomainPlan, type SendingDomainState, type StepAction, type StepPlan, type ZoneSendingState, backoffSchedule };
|
|
216
206
|
//# sourceMappingURL=index.d.mts.map
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"index.d.mts","names":[],"sources":["../../src/email/types.ts","../../src/email/plan.ts","../../src/email/cloudflare.ts","../../src/email/dns.ts","../../src/email/scan.ts"],"mappings":"
|
|
1
|
+
{"version":3,"file":"index.d.mts","names":[],"sources":["../../src/email/types.ts","../../src/email/plan.ts","../../src/email/cloudflare.ts","../../src/email/dns.ts","../../src/email/scan.ts"],"mappings":";;;;;;;;;;;;;;;;;KAiBY;;UAGK;EACf;EACA;;;;;;EAMA;;;UAIe;EACf;EACA;EACA;EACA;EACA;;;UAIe;EACf;EACA;EACA;EACA;;;UAIe;EACf,YAAY;EACZ,UAAU;;;;;;;;;;UAWK;EACf;EACA,YAAY;;KCzCF;UAEK;EACf,QAAQ;;EAER;;UAGe;EACf;;EAEA,SAAS;EACT,SAAS;EACT;;UAGe;EACf,SAAS;EACT;;;wBAIc,WACd,gBACA,UAAU,eACV,OAAO,+BACN;;wBAuCa,mBACd,QAAQ,aACR,QAAQ,YAAY,oBACnB;;wBAqBa,SAAS,GAAG;;wBAKZ,eAAe,GAAG,YAAY;;wBAK9B,WAAW,GAAG;;UAab;EACf;EACA;EACA;;wBAGc,cAAc,SAAS;;wBAWvB,UACd;EAAY;EAAoB;EAAc;KAC9C,UAAU;EACP,gBAAgB;EAAmB;;;;;;;;;;wBCzGlB,gBACpB,eACA,cACA,mBAAkB,QACjB;EAAU;EAAa;EAAgB;EAAiB;EAAmB;;;wBAuBxD,sBACpB,eACA,gBACA,mBAAkB,QACjB;EACD;EACA;EACA;IACE;IACA;IACA;IACA;IACA;;EAEF;;;;;;;wBAgBoB,cACpB,eACA,gBACA,cACA,mBAAkB,QACjB;EAAU;EAAa;EAAgB;EAAc;;;wBAsBlC,WACpB,eACA,gBACA,qBACA,mBAAkB,QACjB;EACD;EACA;EACA;IAAW;IAAoB;IAAc;IAAiB;;EAC9D;;;wBClHoB,WACpB,cACA,mBAAkB,QACjB;EAAU;EAAa;EAAoB;;;;;;;;;;;;;;;;UCjB7B;EACf;;EAEA;EACA;EACA;;;UAIe;EACf;EACA;EACA;;UAGe;EACf,MAAM;EACN,SAAS;;EAET;;;wBAqBc,gBACd,aACA;EACG,MAAM;EAAW,SAAS;;;wBAsCf,qBACd;EAAkB;EAAc;MAC/B"}
|
package/dist/email/index.mjs
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
|
-
import { t as table } from "../plan-format-
|
|
2
|
-
import { t as backoffSchedule } from "../plan-
|
|
1
|
+
import { t as table } from "../plan-format-CIz6Feg2.mjs";
|
|
2
|
+
import { t as backoffSchedule } from "../plan-B9USuc17.mjs";
|
|
3
3
|
|
|
4
|
-
|
|
4
|
+
// src/email/plan.ts
|
|
5
5
|
/** Decide one domain's step from its zone's observed sending names. */
|
|
6
6
|
function planDomain(domain, provider, state) {
|
|
7
7
|
if (provider !== "cloudflare") return {
|
|
@@ -84,8 +84,7 @@ function dnsStatus(expected, resolved) {
|
|
|
84
84
|
}));
|
|
85
85
|
}
|
|
86
86
|
|
|
87
|
-
|
|
88
|
-
//#region src/email/cloudflare.ts
|
|
87
|
+
// src/email/cloudflare.ts
|
|
89
88
|
/**
|
|
90
89
|
* Cloudflare Email Sending — read-only state plus the one write this module makes
|
|
91
90
|
* (`POST … /subdomains`, enable a sending name).
|
|
@@ -211,8 +210,7 @@ async function sendingDns(token, zoneId, subdomainId, fetchImpl = fetch) {
|
|
|
211
210
|
};
|
|
212
211
|
}
|
|
213
212
|
|
|
214
|
-
|
|
215
|
-
//#region src/email/dns.ts
|
|
213
|
+
// src/email/dns.ts
|
|
216
214
|
/**
|
|
217
215
|
* DNS-over-HTTPS propagation probe.
|
|
218
216
|
*
|
|
@@ -243,8 +241,7 @@ async function resolveTxt(name, fetchImpl = fetch) {
|
|
|
243
241
|
};
|
|
244
242
|
}
|
|
245
243
|
|
|
246
|
-
|
|
247
|
-
//#region src/email/scan.ts
|
|
244
|
+
// src/email/scan.ts
|
|
248
245
|
/**
|
|
249
246
|
* `\b` isn't enough of a left boundary: `-` is a non-word character, so a bare
|
|
250
247
|
* `\bfrom` would match inside `data-from=`. Requiring the previous character be
|
|
@@ -326,6 +323,5 @@ function collectFromAddresses(files) {
|
|
|
326
323
|
};
|
|
327
324
|
}
|
|
328
325
|
|
|
329
|
-
//#endregion
|
|
330
326
|
export { backoffSchedule, blockedDomains, collectFromAddresses, dnsStatus, enableSending, extractFromRefs, findZoneForHost, formatPlan, formatSummary, hasDrift, listSendingSubdomains, planDomain, planSendingDomains, resolveTxt, sendingDns };
|
|
331
327
|
//# sourceMappingURL=index.mjs.map
|
package/dist/email/index.mjs.map
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"index.mjs","names":[],"sources":["../../src/email/plan.ts","../../src/email/cloudflare.ts","../../src/email/dns.ts","../../src/email/scan.ts"],"sourcesContent":["/**\n * Every decision `vitops email setup`/`vitops email check` makes, as one pure\n * function.\n *\n * Pure in the load-bearing sense: no `fetch`, no clock. The executors beside this\n * one (`cloudflare.ts`, `dns.ts`) gather live state and apply what they are told;\n * this module decides, from that state, what is left to do — the same split\n * `ads/plan.ts` and `onboarding/plan.ts` make, for the same two payoffs: `--dry`\n * is a complete account of a run, and `--check`/`email check` is an honest drift\n * report.\n *\n * One rule specific to this planner, discovered against a live account: **the\n * onboarded entity is an exact name**, and it is usually a subdomain\n * (`send.example.com`), never the apex a `from` address's domain names. So the\n * most valuable thing this planner can report is not \"onboard this domain\" but\n * \"a sibling in the same zone already IS onboarded — send from there instead.\"\n * That is why a domain absent-with-a-sibling-present is `blocked`, not `create`:\n * creating the apex too is the second option, and it carries a DMARC caveat (see\n * `AGENTS.md`'s Conversion tracking section) that a bare `create` step would hide.\n */\nimport type { EmailConfig, EmailProvider, FromCandidate, ZoneSendingState } from './types.ts';\nimport { table } from '../plan-format.ts';\n\nexport type StepAction = 'create' | 'skip' | 'blocked';\n\nexport interface StepPlan {\n action: StepAction;\n /** Why it's a skip, what the mutation will do, or what a human should do instead. */\n detail: string;\n}\n\nexport interface SendingDomainPlan {\n domain: string;\n /** Where this domain came from — config, derived, or the source scan. */\n sources: FromCandidate['source'][];\n onboard: StepPlan;\n reminders: string[];\n}\n\nexport interface EmailPlan {\n domains: SendingDomainPlan[];\n notes: string[];\n}\n\n/** Decide one domain's step from its zone's observed sending names. */\nexport function planDomain(\n domain: string,\n provider: EmailProvider,\n state: ZoneSendingState | undefined,\n): StepPlan {\n if (provider !== 'cloudflare')\n return { action: 'skip', detail: `no onboarding check implemented for \"${provider}\"` };\n\n if (!state)\n return {\n action: 'blocked',\n detail: `no observed Cloudflare state for \"${domain}\" — is it in this account? (needs CLOUDFLARE_API_TOKEN with Zone:Read + an Email Sending scope)`,\n };\n\n const exact = state.subdomains.find((s) => s.name === domain);\n if (exact) {\n return exact.enabled\n ? { action: 'skip', detail: `already onboarded and enabled` }\n : {\n action: 'create',\n detail: `onboarded but disabled — \\`wrangler email sending enable ${domain}\\` (or re-enable in the dashboard)`,\n };\n }\n\n const sibling = state.subdomains.find((s) => s.enabled);\n if (sibling) {\n return {\n action: 'blocked',\n detail:\n `\"${domain}\" is not onboarded, but \"${sibling.name}\" already is in the same zone — ` +\n `send from \"@${sibling.name}\" instead (isolates transactional mail's DMARC/reputation ` +\n `from the domain's regular mail), or run \\`wrangler email sending enable ${domain}\\` ` +\n `to onboard the apex too (this rewrites _dmarc.${domain} — check what's there first)`,\n };\n }\n\n return {\n action: 'create',\n detail: `not onboarded — \\`wrangler email sending enable ${domain}\\``,\n };\n}\n\n/** Plan every candidate domain. One entry per distinct domain, sources merged. */\nexport function planSendingDomains(\n config: EmailConfig,\n states: Map<string, ZoneSendingState>,\n): EmailPlan {\n const byDomain = new Map<string, FromCandidate['source'][]>();\n for (const c of config.candidates) {\n const sources = byDomain.get(c.domain) ?? [];\n if (!sources.includes(c.source)) sources.push(c.source);\n byDomain.set(c.domain, sources);\n }\n\n const domains: SendingDomainPlan[] = [...byDomain.entries()].map(([domain, sources]) => {\n const onboard = planDomain(domain, config.provider, states.get(domain));\n const reminders =\n onboard.action === 'create' && config.provider === 'cloudflare'\n ? ['DNS takes minutes to propagate before mail sent from it will authenticate.']\n : [];\n return { domain, sources, onboard, reminders };\n });\n\n return { domains, notes: [] };\n}\n\n/** Any step that isn't a skip — i.e. drift, worth reporting. */\nexport function hasDrift(p: EmailPlan): boolean {\n return p.domains.some((d) => d.onboard.action !== 'skip');\n}\n\n/** Every domain still blocked, for a summary that names what's outstanding. */\nexport function blockedDomains(p: EmailPlan): SendingDomainPlan[] {\n return p.domains.filter((d) => d.onboard.action === 'blocked');\n}\n\n/** Render a plan as the block `--dry`/`--check`/`email check` print. */\nexport function formatPlan(p: EmailPlan): string {\n const glyph = (s: StepPlan) => (s.action === 'skip' ? '·' : s.action === 'blocked' ? '?' : '+');\n const lines: string[] = [];\n for (const d of p.domains) {\n lines.push(`${d.domain} (${d.sources.join(', ')})`);\n lines.push(` ${glyph(d.onboard)} ${d.onboard.detail}`);\n for (const r of d.reminders) lines.push(` ! ${r}`);\n }\n for (const n of p.notes) lines.push(`! ${n}`);\n return lines.join('\\n');\n}\n\n/** The outcome of one domain's run, for the summary table. */\nexport interface DomainResult {\n domain: string;\n onboard: 'created' | 'present' | 'blocked' | 'failed';\n reminders: string[];\n}\n\nexport function formatSummary(results: DomainResult[]): string {\n const headers = ['DOMAIN', 'ONBOARD'];\n const rows = results.map((r) => [r.domain, r.onboard]);\n return table(\n headers,\n rows,\n results.flatMap((r) => r.reminders),\n );\n}\n\n/** One expected record vs. what DoH actually resolved for it. Pure — testable with no resolver. */\nexport function dnsStatus(\n expected: { type: 'MX' | 'TXT'; name: string; content: string }[],\n resolved: Map<string, string[]>,\n): { record: (typeof expected)[number]; present: boolean }[] {\n return expected.map((record) => ({\n record,\n present: (resolved.get(record.name) ?? []).some((c) => c === record.content),\n }));\n}\n","/**\n * Cloudflare Email Sending — read-only state plus the one write this module makes\n * (`POST … /subdomains`, enable a sending name).\n *\n * `wrangler email sending list` has no `--json` (checked against wrangler 4.119.0),\n * so this calls the same REST endpoints wrangler itself does rather than scraping\n * its text table — the same reasoning `onboarding/cloudflare.ts` states for why it\n * calls the API directly instead of shelling to a CLI.\n *\n * `fetchImpl` is injected, every call returns a structured result rather than\n * throwing, and the caller decides what a failure means — the same contract as\n * `onboarding/cloudflare.ts` and `ads/plan.ts`'s executor.\n */\nconst API = 'https://api.cloudflare.com/client/v4';\n\ninterface CfEnvelope<T> {\n success: boolean;\n errors?: { code: number; message: string }[];\n result?: T;\n}\n\nconst cfError = (status: number, body: CfEnvelope<unknown>): string => {\n const first = body.errors?.[0];\n return first ? `${first.code} ${first.message}` : `HTTP ${status}`;\n};\n\ninterface RawSubdomain {\n name: string;\n enabled: boolean;\n tag: string;\n dkim_selector?: string;\n return_path_domain?: string;\n}\n\nconst fromRaw = (s: RawSubdomain) => ({\n name: s.name,\n enabled: s.enabled,\n tag: s.tag,\n ...(s.dkim_selector ? { dkimSelector: s.dkim_selector } : {}),\n ...(s.return_path_domain ? { returnPathDomain: s.return_path_domain } : {}),\n});\n\n/**\n * Find the zone a host belongs to, walking labels from most to least specific —\n * the same walk `wrangler`'s own `resolveDomain` does, because a sending name is\n * usually a subdomain (`send.example.com`) and Cloudflare zones are registered at\n * the apex. Tries `send.example.com`, then `example.com`, stopping at the first\n * match. No public-suffix list: Cloudflare simply won't have a zone at a bare\n * suffix like `co.uk`, so the walk is safe without one.\n */\nexport async function findZoneForHost(\n token: string,\n host: string,\n fetchImpl: typeof fetch = fetch,\n): Promise<{ ok: boolean; status: number; zoneId?: string; zoneName?: string; message?: string }> {\n const labels = host.split('.');\n let lastStatus = 0;\n for (let i = 0; i <= labels.length - 2; i++) {\n const candidate = labels.slice(i).join('.');\n const res = await fetchImpl(`${API}/zones?name=${encodeURIComponent(candidate)}`, {\n headers: { authorization: `Bearer ${token}` },\n });\n const body = (await res.json()) as CfEnvelope<{ id: string; name: string }[]>;\n lastStatus = res.status;\n if (!res.ok || !body.success)\n return { ok: false, status: res.status, message: cfError(res.status, body) };\n const zone = body.result?.[0];\n if (zone) return { ok: true, status: res.status, zoneId: zone.id, zoneName: zone.name };\n }\n return {\n ok: false,\n status: lastStatus,\n message: `no Cloudflare zone found for \"${host}\" or any parent domain (is it in this account, and does the token carry Zone:Read?)`,\n };\n}\n\n/** Every sending name onboarded in one zone (`GET /zones/{id}/email/sending/subdomains`). */\nexport async function listSendingSubdomains(\n token: string,\n zoneId: string,\n fetchImpl: typeof fetch = fetch,\n): Promise<{\n ok: boolean;\n status: number;\n subdomains: {\n name: string;\n enabled: boolean;\n tag: string;\n dkimSelector?: string;\n returnPathDomain?: string;\n }[];\n message?: string;\n}> {\n const res = await fetchImpl(`${API}/zones/${zoneId}/email/sending/subdomains`, {\n headers: { authorization: `Bearer ${token}` },\n });\n const body = (await res.json()) as CfEnvelope<RawSubdomain[]>;\n if (!res.ok || !body.success)\n return { ok: false, status: res.status, subdomains: [], message: cfError(res.status, body) };\n return { ok: true, status: res.status, subdomains: (body.result ?? []).map(fromRaw) };\n}\n\n/**\n * Enable Email Sending for a name (`POST … /subdomains`). Create only — there is\n * no update/delete verb here, matching every other onboarding executor's\n * never-remove contract; disabling a live sending domain is left to the operator.\n */\nexport async function enableSending(\n token: string,\n zoneId: string,\n name: string,\n fetchImpl: typeof fetch = fetch,\n): Promise<{ ok: boolean; status: number; tag?: string; message?: string }> {\n const res = await fetchImpl(`${API}/zones/${zoneId}/email/sending/subdomains`, {\n method: 'POST',\n headers: { authorization: `Bearer ${token}`, 'content-type': 'application/json' },\n body: JSON.stringify({ name }),\n });\n const body = (await res.json()) as CfEnvelope<RawSubdomain>;\n if (!res.ok || !body.success)\n return { ok: false, status: res.status, message: cfError(res.status, body) };\n // `tag` doubles as the subdomain id `sendingDns` addresses — Cloudflare's\n // response names it `tag`, not `id`.\n return { ok: true, status: res.status, ...(body.result?.tag ? { tag: body.result.tag } : {}) };\n}\n\ninterface RawDnsRecord {\n type: string;\n name: string;\n content: string;\n priority?: number;\n}\n\n/** The DNS records Cloudflare expects for one sending name (`… /subdomains/{id}/dns`). */\nexport async function sendingDns(\n token: string,\n zoneId: string,\n subdomainId: string,\n fetchImpl: typeof fetch = fetch,\n): Promise<{\n ok: boolean;\n status: number;\n records: { type: 'MX' | 'TXT'; name: string; content: string; priority?: number }[];\n message?: string;\n}> {\n const res = await fetchImpl(\n `${API}/zones/${zoneId}/email/sending/subdomains/${subdomainId}/dns`,\n { headers: { authorization: `Bearer ${token}` } },\n );\n const body = (await res.json()) as CfEnvelope<RawDnsRecord[]>;\n if (!res.ok || !body.success)\n return { ok: false, status: res.status, records: [], message: cfError(res.status, body) };\n const records = (body.result ?? [])\n .filter((r): r is RawDnsRecord & { type: 'MX' | 'TXT' } => r.type === 'MX' || r.type === 'TXT')\n .map((r) => ({\n type: r.type,\n name: r.name,\n // Cloudflare returns TXT content quoted; normalise so an exact compare works.\n content: r.type === 'TXT' ? r.content.replace(/^\"|\"$/g, '') : r.content,\n ...(r.priority != null ? { priority: r.priority } : {}),\n }));\n return { ok: true, status: res.status, records };\n}\n","/**\n * DNS-over-HTTPS propagation probe.\n *\n * Nothing in this repo uses `node:dns` (checked before adding this file — see\n * `AGENTS.md`'s Conversion tracking section for the same reasoning applied to\n * onboarding), and a DNS resolution needs no credential, so DoH is a plain\n * `fetch` rather than a new runtime dependency. This confirms the records\n * Cloudflare's API says it created actually resolve — the Cloudflare API telling\n * you a record exists and the internet being able to see it are different facts,\n * and only the second one lets a visitor's mail actually authenticate.\n *\n * Polling policy (how many attempts, how long between them) is not here —\n * `onboarding/plan.ts`'s `backoffSchedule()` already is, and the CLI does the\n * sleeping, exactly as `search setup`'s DNS_TXT verification retry does.\n */\nconst DOH = 'https://cloudflare-dns.com/dns-query';\n\ninterface DohAnswer {\n name: string;\n type: number;\n data: string;\n}\n\ninterface DohResponse {\n Status: number;\n Answer?: DohAnswer[];\n}\n\n/** Resolve TXT records for a name over DoH. `NXDOMAIN`/no-answer both return `[]`. */\nexport async function resolveTxt(\n name: string,\n fetchImpl: typeof fetch = fetch,\n): Promise<{ ok: boolean; contents: string[]; message?: string }> {\n const res = await fetchImpl(`${DOH}?name=${encodeURIComponent(name)}&type=TXT`, {\n headers: { accept: 'application/dns-json' },\n });\n if (!res.ok) return { ok: false, contents: [], message: `HTTP ${res.status}` };\n const body = (await res.json()) as DohResponse;\n const contents = (body.Answer ?? []).map((a) => a.data.replace(/^\"|\"$/g, ''));\n return { ok: true, contents };\n}\n","/**\n * Best-effort static scan for `from` addresses in consumer source.\n *\n * Config already has the primary source of truth (`site.notifications.email.from`,\n * plus `resolveSender`'s derived default) — this exists only to catch a hand-rolled\n * `EMAIL.send()` call outside `createConversionRoute()`, which the config cannot see.\n *\n * Same judgement call as `icons-scan.ts`'s `extractIconRefs`, for the same reason:\n * a computed value (`from: getSender()`, `from: { email: cfg.from }`) is REPORTED\n * as dynamic, never guessed at. Guessing would produce a candidate list that is\n * confidently wrong, which is worse than one that is honestly incomplete — and\n * it is exactly the false-positive case `--skip`/`verifySender: false` exists for.\n */\n\n/** A statically resolvable `from` address. */\nexport interface FromRef {\n address: string;\n /** `bare` — `from: 'x@y.com'`. `object` — `from: { email: '…' }` / `{ address: '…' }`. */\n kind: 'bare' | 'object';\n file: string;\n line: number;\n}\n\n/** A `from` value that cannot be read statically. */\nexport interface DynamicFromRef {\n expr: string;\n file: string;\n line: number;\n}\n\nexport interface FromScanResult {\n refs: FromRef[];\n dynamic: DynamicFromRef[];\n /** Unique addresses, in first-seen order. */\n addresses: string[];\n}\n\n/**\n * `\\b` isn't enough of a left boundary: `-` is a non-word character, so a bare\n * `\\bfrom` would match inside `data-from=`. Requiring the previous character be\n * neither a word character nor a hyphen keeps the match to a genuine identifier.\n */\nconst LEFT = String.raw`(?<![\\w-])`;\n\n/** A quoted literal, a template literal (rejected if it interpolates), an object literal, or a bare expression. */\nconst VALUE = String.raw`(?:\"([^\"]*)\"|'([^']*)'|\\`([^\\`]*)\\`|(\\{[^}]*\\})|([A-Za-z_$][\\w$.]*(?:\\([^)]*\\))?))`;\n\nconst FROM = new RegExp(String.raw`${LEFT}from\\s*:\\s*${VALUE}`, 'g');\n\n/** `email:`/`address:` inside an already-isolated object-literal slice. */\nconst KEY = /(?<![\\w-])(?:email|address)\\s*:\\s*(?:\"([^\"]*)\"|'([^']*)'|`([^`$]*)`)/;\n\nconst lineOf = (src: string, index: number) => src.slice(0, index).split('\\n').length;\n\n/** Extract every `from` reference from one file's text. */\nexport function extractFromRefs(\n src: string,\n file = '<source>',\n): { refs: FromRef[]; dynamic: DynamicFromRef[] } {\n const refs: FromRef[] = [];\n const dynamic: DynamicFromRef[] = [];\n\n for (const m of src.matchAll(FROM)) {\n const [dq, sq, tpl, obj, ident] = m.slice(1);\n const line = lineOf(src, m.index!);\n\n if (obj !== undefined) {\n const hit = KEY.exec(obj);\n const literal = hit?.[1] ?? hit?.[2] ?? hit?.[3];\n if (literal !== undefined && !literal.includes('${'))\n refs.push({ address: literal, kind: 'object', file, line });\n else dynamic.push({ expr: obj, file, line });\n continue;\n }\n\n if (ident !== undefined) {\n dynamic.push({ expr: ident, file, line });\n continue;\n }\n\n const literal = dq ?? sq ?? tpl;\n if (literal === undefined) continue;\n if (literal.includes('${')) {\n dynamic.push({ expr: literal, file, line });\n continue;\n }\n // A bare `from` string that isn't an email address is very likely an\n // unrelated field (a display name, a form field) — only an '@' makes this a\n // genuine candidate.\n if (literal.includes('@')) refs.push({ address: literal, kind: 'bare', file, line });\n }\n\n return { refs, dynamic };\n}\n\n/** Run `extractFromRefs` across a file set and collect unique addresses. */\nexport function collectFromAddresses(\n files: readonly { path: string; text: string }[],\n): FromScanResult {\n const refs: FromRef[] = [];\n const dynamic: DynamicFromRef[] = [];\n for (const f of files) {\n const r = extractFromRefs(f.text, f.path);\n refs.push(...r.refs);\n dynamic.push(...r.dynamic);\n }\n return { refs, dynamic, addresses: [...new Set(refs.map((r) => r.address))] };\n}\n"],"mappings":"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"}
|
|
1
|
+
{"version":3,"file":"index.mjs","names":[],"sources":["../../src/email/plan.ts","../../src/email/cloudflare.ts","../../src/email/dns.ts","../../src/email/scan.ts"],"sourcesContent":["/**\n * Every decision `vitops email setup`/`vitops email check` makes, as one pure\n * function.\n *\n * Pure in the load-bearing sense: no `fetch`, no clock. The executors beside this\n * one (`cloudflare.ts`, `dns.ts`) gather live state and apply what they are told;\n * this module decides, from that state, what is left to do — the same split\n * `ads/plan.ts` and `onboarding/plan.ts` make, for the same two payoffs: `--dry`\n * is a complete account of a run, and `--check`/`email check` is an honest drift\n * report.\n *\n * One rule specific to this planner, discovered against a live account: **the\n * onboarded entity is an exact name**, and it is usually a subdomain\n * (`send.example.com`), never the apex a `from` address's domain names. So the\n * most valuable thing this planner can report is not \"onboard this domain\" but\n * \"a sibling in the same zone already IS onboarded — send from there instead.\"\n * That is why a domain absent-with-a-sibling-present is `blocked`, not `create`:\n * creating the apex too is the second option, and it carries a DMARC caveat (see\n * `AGENTS.md`'s Conversion tracking section) that a bare `create` step would hide.\n */\nimport type { EmailConfig, EmailProvider, FromCandidate, ZoneSendingState } from './types.ts';\nimport { table } from '../plan-format.ts';\n\nexport type StepAction = 'create' | 'skip' | 'blocked';\n\nexport interface StepPlan {\n action: StepAction;\n /** Why it's a skip, what the mutation will do, or what a human should do instead. */\n detail: string;\n}\n\nexport interface SendingDomainPlan {\n domain: string;\n /** Where this domain came from — config, derived, or the source scan. */\n sources: FromCandidate['source'][];\n onboard: StepPlan;\n reminders: string[];\n}\n\nexport interface EmailPlan {\n domains: SendingDomainPlan[];\n notes: string[];\n}\n\n/** Decide one domain's step from its zone's observed sending names. */\nexport function planDomain(\n domain: string,\n provider: EmailProvider,\n state: ZoneSendingState | undefined,\n): StepPlan {\n if (provider !== 'cloudflare')\n return { action: 'skip', detail: `no onboarding check implemented for \"${provider}\"` };\n\n if (!state)\n return {\n action: 'blocked',\n detail: `no observed Cloudflare state for \"${domain}\" — is it in this account? (needs CLOUDFLARE_API_TOKEN with Zone:Read + an Email Sending scope)`,\n };\n\n const exact = state.subdomains.find((s) => s.name === domain);\n if (exact) {\n return exact.enabled\n ? { action: 'skip', detail: `already onboarded and enabled` }\n : {\n action: 'create',\n detail: `onboarded but disabled — \\`wrangler email sending enable ${domain}\\` (or re-enable in the dashboard)`,\n };\n }\n\n const sibling = state.subdomains.find((s) => s.enabled);\n if (sibling) {\n return {\n action: 'blocked',\n detail:\n `\"${domain}\" is not onboarded, but \"${sibling.name}\" already is in the same zone — ` +\n `send from \"@${sibling.name}\" instead (isolates transactional mail's DMARC/reputation ` +\n `from the domain's regular mail), or run \\`wrangler email sending enable ${domain}\\` ` +\n `to onboard the apex too (this rewrites _dmarc.${domain} — check what's there first)`,\n };\n }\n\n return {\n action: 'create',\n detail: `not onboarded — \\`wrangler email sending enable ${domain}\\``,\n };\n}\n\n/** Plan every candidate domain. One entry per distinct domain, sources merged. */\nexport function planSendingDomains(\n config: EmailConfig,\n states: Map<string, ZoneSendingState>,\n): EmailPlan {\n const byDomain = new Map<string, FromCandidate['source'][]>();\n for (const c of config.candidates) {\n const sources = byDomain.get(c.domain) ?? [];\n if (!sources.includes(c.source)) sources.push(c.source);\n byDomain.set(c.domain, sources);\n }\n\n const domains: SendingDomainPlan[] = [...byDomain.entries()].map(([domain, sources]) => {\n const onboard = planDomain(domain, config.provider, states.get(domain));\n const reminders =\n onboard.action === 'create' && config.provider === 'cloudflare'\n ? ['DNS takes minutes to propagate before mail sent from it will authenticate.']\n : [];\n return { domain, sources, onboard, reminders };\n });\n\n return { domains, notes: [] };\n}\n\n/** Any step that isn't a skip — i.e. drift, worth reporting. */\nexport function hasDrift(p: EmailPlan): boolean {\n return p.domains.some((d) => d.onboard.action !== 'skip');\n}\n\n/** Every domain still blocked, for a summary that names what's outstanding. */\nexport function blockedDomains(p: EmailPlan): SendingDomainPlan[] {\n return p.domains.filter((d) => d.onboard.action === 'blocked');\n}\n\n/** Render a plan as the block `--dry`/`--check`/`email check` print. */\nexport function formatPlan(p: EmailPlan): string {\n const glyph = (s: StepPlan) => (s.action === 'skip' ? '·' : s.action === 'blocked' ? '?' : '+');\n const lines: string[] = [];\n for (const d of p.domains) {\n lines.push(`${d.domain} (${d.sources.join(', ')})`);\n lines.push(` ${glyph(d.onboard)} ${d.onboard.detail}`);\n for (const r of d.reminders) lines.push(` ! ${r}`);\n }\n for (const n of p.notes) lines.push(`! ${n}`);\n return lines.join('\\n');\n}\n\n/** The outcome of one domain's run, for the summary table. */\nexport interface DomainResult {\n domain: string;\n onboard: 'created' | 'present' | 'blocked' | 'failed';\n reminders: string[];\n}\n\nexport function formatSummary(results: DomainResult[]): string {\n const headers = ['DOMAIN', 'ONBOARD'];\n const rows = results.map((r) => [r.domain, r.onboard]);\n return table(\n headers,\n rows,\n results.flatMap((r) => r.reminders),\n );\n}\n\n/** One expected record vs. what DoH actually resolved for it. Pure — testable with no resolver. */\nexport function dnsStatus(\n expected: { type: 'MX' | 'TXT'; name: string; content: string }[],\n resolved: Map<string, string[]>,\n): { record: (typeof expected)[number]; present: boolean }[] {\n return expected.map((record) => ({\n record,\n present: (resolved.get(record.name) ?? []).some((c) => c === record.content),\n }));\n}\n","/**\n * Cloudflare Email Sending — read-only state plus the one write this module makes\n * (`POST … /subdomains`, enable a sending name).\n *\n * `wrangler email sending list` has no `--json` (checked against wrangler 4.119.0),\n * so this calls the same REST endpoints wrangler itself does rather than scraping\n * its text table — the same reasoning `onboarding/cloudflare.ts` states for why it\n * calls the API directly instead of shelling to a CLI.\n *\n * `fetchImpl` is injected, every call returns a structured result rather than\n * throwing, and the caller decides what a failure means — the same contract as\n * `onboarding/cloudflare.ts` and `ads/plan.ts`'s executor.\n */\nconst API = 'https://api.cloudflare.com/client/v4';\n\ninterface CfEnvelope<T> {\n success: boolean;\n errors?: { code: number; message: string }[];\n result?: T;\n}\n\nconst cfError = (status: number, body: CfEnvelope<unknown>): string => {\n const first = body.errors?.[0];\n return first ? `${first.code} ${first.message}` : `HTTP ${status}`;\n};\n\ninterface RawSubdomain {\n name: string;\n enabled: boolean;\n tag: string;\n dkim_selector?: string;\n return_path_domain?: string;\n}\n\nconst fromRaw = (s: RawSubdomain) => ({\n name: s.name,\n enabled: s.enabled,\n tag: s.tag,\n ...(s.dkim_selector ? { dkimSelector: s.dkim_selector } : {}),\n ...(s.return_path_domain ? { returnPathDomain: s.return_path_domain } : {}),\n});\n\n/**\n * Find the zone a host belongs to, walking labels from most to least specific —\n * the same walk `wrangler`'s own `resolveDomain` does, because a sending name is\n * usually a subdomain (`send.example.com`) and Cloudflare zones are registered at\n * the apex. Tries `send.example.com`, then `example.com`, stopping at the first\n * match. No public-suffix list: Cloudflare simply won't have a zone at a bare\n * suffix like `co.uk`, so the walk is safe without one.\n */\nexport async function findZoneForHost(\n token: string,\n host: string,\n fetchImpl: typeof fetch = fetch,\n): Promise<{ ok: boolean; status: number; zoneId?: string; zoneName?: string; message?: string }> {\n const labels = host.split('.');\n let lastStatus = 0;\n for (let i = 0; i <= labels.length - 2; i++) {\n const candidate = labels.slice(i).join('.');\n const res = await fetchImpl(`${API}/zones?name=${encodeURIComponent(candidate)}`, {\n headers: { authorization: `Bearer ${token}` },\n });\n const body = (await res.json()) as CfEnvelope<{ id: string; name: string }[]>;\n lastStatus = res.status;\n if (!res.ok || !body.success)\n return { ok: false, status: res.status, message: cfError(res.status, body) };\n const zone = body.result?.[0];\n if (zone) return { ok: true, status: res.status, zoneId: zone.id, zoneName: zone.name };\n }\n return {\n ok: false,\n status: lastStatus,\n message: `no Cloudflare zone found for \"${host}\" or any parent domain (is it in this account, and does the token carry Zone:Read?)`,\n };\n}\n\n/** Every sending name onboarded in one zone (`GET /zones/{id}/email/sending/subdomains`). */\nexport async function listSendingSubdomains(\n token: string,\n zoneId: string,\n fetchImpl: typeof fetch = fetch,\n): Promise<{\n ok: boolean;\n status: number;\n subdomains: {\n name: string;\n enabled: boolean;\n tag: string;\n dkimSelector?: string;\n returnPathDomain?: string;\n }[];\n message?: string;\n}> {\n const res = await fetchImpl(`${API}/zones/${zoneId}/email/sending/subdomains`, {\n headers: { authorization: `Bearer ${token}` },\n });\n const body = (await res.json()) as CfEnvelope<RawSubdomain[]>;\n if (!res.ok || !body.success)\n return { ok: false, status: res.status, subdomains: [], message: cfError(res.status, body) };\n return { ok: true, status: res.status, subdomains: (body.result ?? []).map(fromRaw) };\n}\n\n/**\n * Enable Email Sending for a name (`POST … /subdomains`). Create only — there is\n * no update/delete verb here, matching every other onboarding executor's\n * never-remove contract; disabling a live sending domain is left to the operator.\n */\nexport async function enableSending(\n token: string,\n zoneId: string,\n name: string,\n fetchImpl: typeof fetch = fetch,\n): Promise<{ ok: boolean; status: number; tag?: string; message?: string }> {\n const res = await fetchImpl(`${API}/zones/${zoneId}/email/sending/subdomains`, {\n method: 'POST',\n headers: { authorization: `Bearer ${token}`, 'content-type': 'application/json' },\n body: JSON.stringify({ name }),\n });\n const body = (await res.json()) as CfEnvelope<RawSubdomain>;\n if (!res.ok || !body.success)\n return { ok: false, status: res.status, message: cfError(res.status, body) };\n // `tag` doubles as the subdomain id `sendingDns` addresses — Cloudflare's\n // response names it `tag`, not `id`.\n return { ok: true, status: res.status, ...(body.result?.tag ? { tag: body.result.tag } : {}) };\n}\n\ninterface RawDnsRecord {\n type: string;\n name: string;\n content: string;\n priority?: number;\n}\n\n/** The DNS records Cloudflare expects for one sending name (`… /subdomains/{id}/dns`). */\nexport async function sendingDns(\n token: string,\n zoneId: string,\n subdomainId: string,\n fetchImpl: typeof fetch = fetch,\n): Promise<{\n ok: boolean;\n status: number;\n records: { type: 'MX' | 'TXT'; name: string; content: string; priority?: number }[];\n message?: string;\n}> {\n const res = await fetchImpl(\n `${API}/zones/${zoneId}/email/sending/subdomains/${subdomainId}/dns`,\n { headers: { authorization: `Bearer ${token}` } },\n );\n const body = (await res.json()) as CfEnvelope<RawDnsRecord[]>;\n if (!res.ok || !body.success)\n return { ok: false, status: res.status, records: [], message: cfError(res.status, body) };\n const records = (body.result ?? [])\n .filter((r): r is RawDnsRecord & { type: 'MX' | 'TXT' } => r.type === 'MX' || r.type === 'TXT')\n .map((r) => ({\n type: r.type,\n name: r.name,\n // Cloudflare returns TXT content quoted; normalise so an exact compare works.\n content: r.type === 'TXT' ? r.content.replace(/^\"|\"$/g, '') : r.content,\n ...(r.priority != null ? { priority: r.priority } : {}),\n }));\n return { ok: true, status: res.status, records };\n}\n","/**\n * DNS-over-HTTPS propagation probe.\n *\n * Nothing in this repo uses `node:dns` (checked before adding this file — see\n * `AGENTS.md`'s Conversion tracking section for the same reasoning applied to\n * onboarding), and a DNS resolution needs no credential, so DoH is a plain\n * `fetch` rather than a new runtime dependency. This confirms the records\n * Cloudflare's API says it created actually resolve — the Cloudflare API telling\n * you a record exists and the internet being able to see it are different facts,\n * and only the second one lets a visitor's mail actually authenticate.\n *\n * Polling policy (how many attempts, how long between them) is not here —\n * `onboarding/plan.ts`'s `backoffSchedule()` already is, and the CLI does the\n * sleeping, exactly as `search setup`'s DNS_TXT verification retry does.\n */\nconst DOH = 'https://cloudflare-dns.com/dns-query';\n\ninterface DohAnswer {\n name: string;\n type: number;\n data: string;\n}\n\ninterface DohResponse {\n Status: number;\n Answer?: DohAnswer[];\n}\n\n/** Resolve TXT records for a name over DoH. `NXDOMAIN`/no-answer both return `[]`. */\nexport async function resolveTxt(\n name: string,\n fetchImpl: typeof fetch = fetch,\n): Promise<{ ok: boolean; contents: string[]; message?: string }> {\n const res = await fetchImpl(`${DOH}?name=${encodeURIComponent(name)}&type=TXT`, {\n headers: { accept: 'application/dns-json' },\n });\n if (!res.ok) return { ok: false, contents: [], message: `HTTP ${res.status}` };\n const body = (await res.json()) as DohResponse;\n const contents = (body.Answer ?? []).map((a) => a.data.replace(/^\"|\"$/g, ''));\n return { ok: true, contents };\n}\n","/**\n * Best-effort static scan for `from` addresses in consumer source.\n *\n * Config already has the primary source of truth (`site.notifications.email.from`,\n * plus `resolveSender`'s derived default) — this exists only to catch a hand-rolled\n * `EMAIL.send()` call outside `createConversionRoute()`, which the config cannot see.\n *\n * Same judgement call as `icons-scan.ts`'s `extractIconRefs`, for the same reason:\n * a computed value (`from: getSender()`, `from: { email: cfg.from }`) is REPORTED\n * as dynamic, never guessed at. Guessing would produce a candidate list that is\n * confidently wrong, which is worse than one that is honestly incomplete — and\n * it is exactly the false-positive case `--skip`/`verifySender: false` exists for.\n */\n\n/** A statically resolvable `from` address. */\nexport interface FromRef {\n address: string;\n /** `bare` — `from: 'x@y.com'`. `object` — `from: { email: '…' }` / `{ address: '…' }`. */\n kind: 'bare' | 'object';\n file: string;\n line: number;\n}\n\n/** A `from` value that cannot be read statically. */\nexport interface DynamicFromRef {\n expr: string;\n file: string;\n line: number;\n}\n\nexport interface FromScanResult {\n refs: FromRef[];\n dynamic: DynamicFromRef[];\n /** Unique addresses, in first-seen order. */\n addresses: string[];\n}\n\n/**\n * `\\b` isn't enough of a left boundary: `-` is a non-word character, so a bare\n * `\\bfrom` would match inside `data-from=`. Requiring the previous character be\n * neither a word character nor a hyphen keeps the match to a genuine identifier.\n */\nconst LEFT = String.raw`(?<![\\w-])`;\n\n/** A quoted literal, a template literal (rejected if it interpolates), an object literal, or a bare expression. */\nconst VALUE = String.raw`(?:\"([^\"]*)\"|'([^']*)'|\\`([^\\`]*)\\`|(\\{[^}]*\\})|([A-Za-z_$][\\w$.]*(?:\\([^)]*\\))?))`;\n\nconst FROM = new RegExp(String.raw`${LEFT}from\\s*:\\s*${VALUE}`, 'g');\n\n/** `email:`/`address:` inside an already-isolated object-literal slice. */\nconst KEY = /(?<![\\w-])(?:email|address)\\s*:\\s*(?:\"([^\"]*)\"|'([^']*)'|`([^`$]*)`)/;\n\nconst lineOf = (src: string, index: number) => src.slice(0, index).split('\\n').length;\n\n/** Extract every `from` reference from one file's text. */\nexport function extractFromRefs(\n src: string,\n file = '<source>',\n): { refs: FromRef[]; dynamic: DynamicFromRef[] } {\n const refs: FromRef[] = [];\n const dynamic: DynamicFromRef[] = [];\n\n for (const m of src.matchAll(FROM)) {\n const [dq, sq, tpl, obj, ident] = m.slice(1);\n const line = lineOf(src, m.index!);\n\n if (obj !== undefined) {\n const hit = KEY.exec(obj);\n const literal = hit?.[1] ?? hit?.[2] ?? hit?.[3];\n if (literal !== undefined && !literal.includes('${'))\n refs.push({ address: literal, kind: 'object', file, line });\n else dynamic.push({ expr: obj, file, line });\n continue;\n }\n\n if (ident !== undefined) {\n dynamic.push({ expr: ident, file, line });\n continue;\n }\n\n const literal = dq ?? sq ?? tpl;\n if (literal === undefined) continue;\n if (literal.includes('${')) {\n dynamic.push({ expr: literal, file, line });\n continue;\n }\n // A bare `from` string that isn't an email address is very likely an\n // unrelated field (a display name, a form field) — only an '@' makes this a\n // genuine candidate.\n if (literal.includes('@')) refs.push({ address: literal, kind: 'bare', file, line });\n }\n\n return { refs, dynamic };\n}\n\n/** Run `extractFromRefs` across a file set and collect unique addresses. */\nexport function collectFromAddresses(\n files: readonly { path: string; text: string }[],\n): FromScanResult {\n const refs: FromRef[] = [];\n const dynamic: DynamicFromRef[] = [];\n for (const f of files) {\n const r = extractFromRefs(f.text, f.path);\n refs.push(...r.refs);\n dynamic.push(...r.dynamic);\n }\n return { refs, dynamic, addresses: [...new Set(refs.map((r) => r.address))] };\n}\n"],"mappings":"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"}
|
package/dist/favicon.d.mts
CHANGED
|
@@ -1,4 +1,3 @@
|
|
|
1
|
-
//#region src/favicon.d.ts
|
|
2
1
|
export interface FaviconOptions {
|
|
3
2
|
/** Source image — an SVG or PNG. */
|
|
4
3
|
source: string;
|
|
@@ -59,5 +58,4 @@ export interface WebManifestOptions {
|
|
|
59
58
|
export declare function faviconManifest(opts: WebManifestOptions): Record<string, unknown>;
|
|
60
59
|
/** Write `site.webmanifest` into `dir`. Returns the written path. */
|
|
61
60
|
export declare function writeFaviconManifest(dir: string, opts: WebManifestOptions): Promise<string>;
|
|
62
|
-
//#endregion
|
|
63
61
|
//# sourceMappingURL=favicon.d.mts.map
|
package/dist/favicon.d.mts.map
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"favicon.d.mts","names":[],"sources":["../src/favicon.ts"],"mappings":"
|
|
1
|
+
{"version":3,"file":"favicon.d.mts","names":[],"sources":["../src/favicon.ts"],"mappings":"A,iBAiBiB;;EAEf;;EAEA;;EAEA;;EAEA;;;;;;;;;;;;;EAaA;;;;;;;wBA0EoB,iBAAiB,MAAM,iBAAiB;iBA4G7C;EACf;EACA;EACA;EACA;;iBAGe;;EAEf;;EAEA;;EAEA;;;;;;wBAOc,aAAa,OAAM,oBAAyB;iBAW3C;EACf;EACA;EACA;EACA;;EAEA;;;wBAIc,gBAAgB,MAAM,qBAAqB;;wBAuBrC,qBAAqB,aAAa,MAAM,qBAAqB"}
|
package/dist/favicon.mjs
CHANGED
|
@@ -4,7 +4,7 @@ import { promisify } from "node:util";
|
|
|
4
4
|
import cp from "node:child_process";
|
|
5
5
|
import { join } from "node:path";
|
|
6
6
|
|
|
7
|
-
|
|
7
|
+
// src/favicon.ts
|
|
8
8
|
/**
|
|
9
9
|
* Favicon generation — rasterize a source SVG/PNG into the standard favicon set.
|
|
10
10
|
*
|
|
@@ -231,6 +231,5 @@ async function writeFaviconManifest(dir, opts) {
|
|
|
231
231
|
return path;
|
|
232
232
|
}
|
|
233
233
|
|
|
234
|
-
//#endregion
|
|
235
234
|
export { faviconLinks, faviconManifest, generateFavicons, writeFaviconManifest };
|
|
236
235
|
//# sourceMappingURL=favicon.mjs.map
|
package/dist/git/index.d.mts
CHANGED
|
@@ -1,4 +1,3 @@
|
|
|
1
|
-
//#region src/git/github.d.ts
|
|
2
1
|
/**
|
|
3
2
|
* GitHub's Contents API — `commitFile()` creates or updates a single file with
|
|
4
3
|
* one commit, called from the EmDash design-system-editor plugin route (see
|
|
@@ -60,6 +59,5 @@ interface CommitFileResult {
|
|
|
60
59
|
* message }`, matching every other executor in this toolchain.
|
|
61
60
|
*/
|
|
62
61
|
export declare function commitFile(opts: CommitFileOptions): Promise<CommitFileResult>;
|
|
63
|
-
//#endregion
|
|
64
62
|
export type { CommitFileOptions, CommitFileResult };
|
|
65
63
|
//# sourceMappingURL=index.d.mts.map
|
package/dist/git/index.d.mts.map
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"index.d.mts","names":[],"sources":["../../src/git/github.ts"],"mappings":"
|
|
1
|
+
{"version":3,"file":"index.d.mts","names":[],"sources":["../../src/git/github.ts"],"mappings":";;;;;;;;;;;;;;;;;;;;;UAwBiB;;EAEf;EACA;;EAEA;;EAEA;;EAEA;;EAEA;;EAEA;;;;;;;;EAQA,cAAc,aAAa,OAAO,gBAAgB,QAAQ;;UAG3C;EACf;EACA;;EAEA;EACA;;;;;;;;;;;wBA6BoB,WAAW,MAAM,oBAAoB,QAAQ"}
|
package/dist/git/index.mjs
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
|
|
1
|
+
// src/git/github.ts
|
|
2
2
|
/**
|
|
3
3
|
* GitHub's Contents API — `commitFile()` creates or updates a single file with
|
|
4
4
|
* one commit, called from the EmDash design-system-editor plugin route (see
|
|
@@ -102,6 +102,5 @@ async function commitFile(opts) {
|
|
|
102
102
|
}
|
|
103
103
|
}
|
|
104
104
|
|
|
105
|
-
//#endregion
|
|
106
105
|
export { commitFile };
|
|
107
106
|
//# sourceMappingURL=index.mjs.map
|