@hostwebhook/platform-node 0.2.0 → 0.3.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/dist/index.d.ts +4 -0
- package/dist/index.js +7 -0
- package/dist/presets-de-oauth/github-oauth-presets.d.ts +199 -0
- package/dist/presets-de-oauth/github-oauth-presets.js +142 -0
- package/dist/presets-de-oauth/linkedin-oauth-presets.d.ts +314 -0
- package/dist/presets-de-oauth/linkedin-oauth-presets.js +338 -0
- package/dist/presets-de-oauth/mailchimp-oauth-presets.d.ts +135 -0
- package/dist/presets-de-oauth/mailchimp-oauth-presets.js +96 -0
- package/dist/presets-de-oauth/shopify-oauth-presets.d.ts +209 -0
- package/dist/presets-de-oauth/shopify-oauth-presets.js +223 -0
- package/package.json +1 -1
|
@@ -0,0 +1,338 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
/**
|
|
3
|
+
* LinkedIn OAuth 2.0 configuration for HostWebhook.
|
|
4
|
+
*
|
|
5
|
+
* LinkedIn's auth model is plain OAuth 2.0 (NOT PKCE) with a
|
|
6
|
+
* confidential client. No per-instance dance like Mastodon — single
|
|
7
|
+
* HW-wide registered app, env-var-driven client credentials.
|
|
8
|
+
*
|
|
9
|
+
* ## SON DOS APPS DE LINKEDIN, no una
|
|
10
|
+
*
|
|
11
|
+
* LinkedIn exige que **Community Management API sea el unico producto** de su
|
|
12
|
+
* aplicacion, «due to legal and security constraints». En una app que ya tenga
|
|
13
|
+
* Share o Sign In with LinkedIn, la opcion de pedirlo sale en gris; y donde ya
|
|
14
|
+
* esta concedido, activar OIDC lo deshabilita. No es un fallo de configuracion
|
|
15
|
+
* ni algo que se arregle insistiendo: son dos aplicaciones.
|
|
16
|
+
*
|
|
17
|
+
* App MIEMBRO — https://www.linkedin.com/developers/apps
|
|
18
|
+
* Products: "Sign In with LinkedIn using OpenID Connect" + "Share on
|
|
19
|
+
* LinkedIn". Gratuitas y automaticas.
|
|
20
|
+
* Da: openid, profile, email, w_member_social.
|
|
21
|
+
* Sirve para: publicar en un perfil personal.
|
|
22
|
+
* Env: LINKEDIN_OAUTH_CLIENT_ID / LINKEDIN_OAUTH_CLIENT_SECRET.
|
|
23
|
+
*
|
|
24
|
+
* App PAGINA — otra aplicacion, creada limpia y SIN anadirle ningun otro
|
|
25
|
+
* producto antes de pedirlo.
|
|
26
|
+
* Products: "Community Management API", y nada mas. Pasa por revision.
|
|
27
|
+
* Da: r_organization_social, w_organization_social, rw_organization_admin.
|
|
28
|
+
* Sirve para: publicar como pagina Y SONDEAR sus posts (lo unico que
|
|
29
|
+
* desbloquea el trigger).
|
|
30
|
+
* Env: LINKEDIN_ORG_OAUTH_CLIENT_ID / LINKEDIN_ORG_OAUTH_CLIENT_SECRET.
|
|
31
|
+
*
|
|
32
|
+
* Las dos registran el MISMO redirect: `{API_URL}/api/credentials/linkedin/
|
|
33
|
+
* oauth/callback`. No hay ambiguedad al volver porque el `state` guardado en
|
|
34
|
+
* Redis dice de que app salio, y con el la pareja de credenciales correcta.
|
|
35
|
+
*
|
|
36
|
+
* ## Lo que NO tiene la app de pagina
|
|
37
|
+
*
|
|
38
|
+
* OIDC. Ahi no hay `openid`, asi que `/v2/userinfo` no se puede llamar y esa
|
|
39
|
+
* conexion no conoce a la persona: se identifica por las paginas que
|
|
40
|
+
* administra (`organizationAcls`). De ahi que `memberId` y `personUrn` sean
|
|
41
|
+
* opcionales — ver `LinkedInOAuthStoredAuth`.
|
|
42
|
+
*
|
|
43
|
+
* Token lifetime: 60 days (default). Refresh tokens optional and
|
|
44
|
+
* issued only when LinkedIn deems necessary — most HW deployments
|
|
45
|
+
* just persist the access token until expiry, then prompt reconnect.
|
|
46
|
+
*
|
|
47
|
+
* ## Las dos identidades
|
|
48
|
+
*
|
|
49
|
+
* Una credencial de LinkedIn representa **a quien se publica y a quien se
|
|
50
|
+
* sondea**, y eso puede ser una persona (`urn:li:person:{sub}`) o una pagina
|
|
51
|
+
* de empresa (`urn:li:organization:{id}`). Todo el provider ya trabajaba con
|
|
52
|
+
* un solo `authorUrn`, asi que la eleccion vive en la credencial y ni el nodo
|
|
53
|
+
* ni el trigger tienen que enterarse: cambiar de identidad es cambiar ese URN.
|
|
54
|
+
*
|
|
55
|
+
* La diferencia que importa esta en la LECTURA, no en la escritura:
|
|
56
|
+
*
|
|
57
|
+
* - publicar como persona -> `w_member_social`, producto gratuito.
|
|
58
|
+
* - publicar como pagina -> `w_organization_social`, Community Management.
|
|
59
|
+
* - LEER los posts propios de una persona -> `r_member_social`, que LinkedIn
|
|
60
|
+
* describe como «restricted and available to approved users only» y no
|
|
61
|
+
* esta concediendo. Por eso el trigger sobre un perfil personal no puede
|
|
62
|
+
* funcionar por mucho que la app este bien montada.
|
|
63
|
+
* - LEER los posts de una pagina -> `r_organization_social`, y ese SI se
|
|
64
|
+
* concede via Community Management API.
|
|
65
|
+
*
|
|
66
|
+
* De ahi que el trigger sea una funcion de la identidad elegida y de los
|
|
67
|
+
* scopes concedidos, no de una tabla fija: si LinkedIn cambiara de criterio y
|
|
68
|
+
* concediera `r_member_social`, el perfil personal empieza a sondear solo, sin
|
|
69
|
+
* tocar una linea. Ver `pollBlockReason`.
|
|
70
|
+
*/
|
|
71
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
72
|
+
exports.LINKEDIN_ORG_ADMIN_SCOPE = exports.LINKEDIN_ORG_WRITE_SCOPE = exports.LINKEDIN_ORG_READ_SCOPE = exports.LINKEDIN_MEMBER_READ_SCOPE = exports.LINKEDIN_REST_VERSION = exports.LINKEDIN_OAUTH_CONFIG = void 0;
|
|
73
|
+
exports.resolveDefaultScopes = resolveDefaultScopes;
|
|
74
|
+
exports.resolveOrgScopes = resolveOrgScopes;
|
|
75
|
+
exports.appConfigFor = appConfigFor;
|
|
76
|
+
exports.parseScopeList = parseScopeList;
|
|
77
|
+
exports.authorKindOf = authorKindOf;
|
|
78
|
+
exports.parseOrganizationAcls = parseOrganizationAcls;
|
|
79
|
+
exports.personUrnOf = personUrnOf;
|
|
80
|
+
exports.toOrganizations = toOrganizations;
|
|
81
|
+
exports.pollBlockReason = pollBlockReason;
|
|
82
|
+
exports.postBlockReason = postBlockReason;
|
|
83
|
+
exports.LINKEDIN_OAUTH_CONFIG = {
|
|
84
|
+
authorizationEndpoint: 'https://www.linkedin.com/oauth/v2/authorization',
|
|
85
|
+
tokenEndpoint: 'https://www.linkedin.com/oauth/v2/accessToken',
|
|
86
|
+
/** OpenID Connect userinfo — returns sub (member id), name, email, etc. */
|
|
87
|
+
userInfoWebhook: 'https://api.linkedin.com/v2/userinfo',
|
|
88
|
+
/** Env var names holding HW's registered client credentials. */
|
|
89
|
+
clientIdEnv: 'LINKEDIN_OAUTH_CLIENT_ID',
|
|
90
|
+
clientSecretEnv: 'LINKEDIN_OAUTH_CLIENT_SECRET',
|
|
91
|
+
/**
|
|
92
|
+
* Scopes extra que este despliegue puede pedir, separados por coma.
|
|
93
|
+
*
|
|
94
|
+
* ## Por que es una variable y no una constante
|
|
95
|
+
*
|
|
96
|
+
* LinkedIn rechaza la autorizacion ENTERA con `unauthorized_scope_error` si
|
|
97
|
+
* pides un scope que la app no tiene habilitado — no lo ignora. Meter los
|
|
98
|
+
* scopes de pagina en la lista fija romperia el conector personal, que hoy
|
|
99
|
+
* funciona, en todos los despliegues cuya app aun no tenga aprobado
|
|
100
|
+
* Community Management API.
|
|
101
|
+
*
|
|
102
|
+
* Asi que la lista base es la que garantizan los dos productos gratuitos, y
|
|
103
|
+
* lo que dependa de una aprobacion se enciende aqui cuando LinkedIn la
|
|
104
|
+
* concede:
|
|
105
|
+
*
|
|
106
|
+
* LINKEDIN_OAUTH_EXTRA_SCOPES=r_organization_social,w_organization_social,rw_organization_admin
|
|
107
|
+
*
|
|
108
|
+
* Es una variable y no un booleano a proposito: LinkedIn renombra y trocea
|
|
109
|
+
* sus permisos cada pocos meses, y asi ajustarlo no pide un despliegue de
|
|
110
|
+
* codigo.
|
|
111
|
+
*/
|
|
112
|
+
extraScopesEnv: 'LINKEDIN_OAUTH_EXTRA_SCOPES',
|
|
113
|
+
/** Default scope set:
|
|
114
|
+
* - openid profile email — OIDC identity (always required)
|
|
115
|
+
* - w_member_social — post on behalf of the authenticated member
|
|
116
|
+
*
|
|
117
|
+
* `w_member_social` is "Share on LinkedIn" product scope — must be
|
|
118
|
+
* enabled on the LinkedIn app via the Products tab. */
|
|
119
|
+
defaultScopes: ['openid', 'profile', 'email', 'w_member_social'],
|
|
120
|
+
/** Credenciales de la SEGUNDA app, la de Community Management API. */
|
|
121
|
+
orgClientIdEnv: 'LINKEDIN_ORG_OAUTH_CLIENT_ID',
|
|
122
|
+
orgClientSecretEnv: 'LINKEDIN_ORG_OAUTH_CLIENT_SECRET',
|
|
123
|
+
/** Sobrescribe los scopes de la app de pagina sin desplegar codigo. */
|
|
124
|
+
orgScopesEnv: 'LINKEDIN_ORG_OAUTH_SCOPES',
|
|
125
|
+
/**
|
|
126
|
+
* Lo que concede Community Management API en su nivel de desarrollo:
|
|
127
|
+
* - r_organization_social — leer los posts de la pagina (EL TRIGGER)
|
|
128
|
+
* - w_organization_social — publicar como la pagina
|
|
129
|
+
* - rw_organization_admin — listar que paginas administra el miembro
|
|
130
|
+
*
|
|
131
|
+
* Sin `openid` a proposito: esa app no tiene OIDC y pedirlo la tumbaria
|
|
132
|
+
* entera con `unauthorized_scope_error`.
|
|
133
|
+
*/
|
|
134
|
+
orgDefaultScopes: [
|
|
135
|
+
'r_organization_social',
|
|
136
|
+
'w_organization_social',
|
|
137
|
+
'rw_organization_admin',
|
|
138
|
+
],
|
|
139
|
+
};
|
|
140
|
+
/**
|
|
141
|
+
* La version de la API de marketing que se declara en cada llamada a `/rest`.
|
|
142
|
+
*
|
|
143
|
+
* Iba escrita en el provider, y ahora la necesitan dos sitios: el provider y el
|
|
144
|
+
* descubrimiento de paginas del OAuth. Una sola copia, porque LinkedIn RETIRA
|
|
145
|
+
* las versiones viejas cada pocos meses («The Marketing Version 202507 has been
|
|
146
|
+
* sunset») y en `/rest` una version retirada no es un aviso, es un fallo. Con
|
|
147
|
+
* dos copias, subir una y olvidar la otra deja media integracion rota.
|
|
148
|
+
*
|
|
149
|
+
* Los `/v2` no la consultan — de ahi que el desfase no diera la cara.
|
|
150
|
+
*/
|
|
151
|
+
exports.LINKEDIN_REST_VERSION = '202607';
|
|
152
|
+
/** Leer los posts propios de una PERSONA. Restringido por LinkedIn. */
|
|
153
|
+
exports.LINKEDIN_MEMBER_READ_SCOPE = 'r_member_social';
|
|
154
|
+
/** Leer los posts de una PAGINA. Community Management API. */
|
|
155
|
+
exports.LINKEDIN_ORG_READ_SCOPE = 'r_organization_social';
|
|
156
|
+
/** Publicar como PAGINA. Community Management API. */
|
|
157
|
+
exports.LINKEDIN_ORG_WRITE_SCOPE = 'w_organization_social';
|
|
158
|
+
/** Listar las paginas que administra el miembro (`organizationAcls`). */
|
|
159
|
+
exports.LINKEDIN_ORG_ADMIN_SCOPE = 'rw_organization_admin';
|
|
160
|
+
/**
|
|
161
|
+
* Los scopes que se piden en la autorizacion: los base mas los que este
|
|
162
|
+
* despliegue haya encendido por entorno.
|
|
163
|
+
*
|
|
164
|
+
* `env` se inyecta para que la prueba no tenga que tocar `process.env`.
|
|
165
|
+
*/
|
|
166
|
+
function resolveDefaultScopes(env = process.env) {
|
|
167
|
+
const extra = parseScopeList(env[exports.LINKEDIN_OAUTH_CONFIG.extraScopesEnv]);
|
|
168
|
+
return dedupe([...exports.LINKEDIN_OAUTH_CONFIG.defaultScopes, ...extra]);
|
|
169
|
+
}
|
|
170
|
+
/** Lo que se le pide a la app de pagina. Sobrescribible por entorno. */
|
|
171
|
+
function resolveOrgScopes(env = process.env) {
|
|
172
|
+
const override = parseScopeList(env[exports.LINKEDIN_OAUTH_CONFIG.orgScopesEnv]);
|
|
173
|
+
/* Aqui SUSTITUYE en vez de sumar: si LinkedIn concede un juego distinto al
|
|
174
|
+
aprobar, sumarle los nuestros pediria uno que no tiene y tumbaria la
|
|
175
|
+
autorizacion entera — que es justo el accidente del que huimos. */
|
|
176
|
+
return override.length > 0
|
|
177
|
+
? dedupe(override)
|
|
178
|
+
: [...exports.LINKEDIN_OAUTH_CONFIG.orgDefaultScopes];
|
|
179
|
+
}
|
|
180
|
+
/** Los scopes y los nombres de variable que le tocan a cada app. */
|
|
181
|
+
function appConfigFor(kind) {
|
|
182
|
+
return kind === 'organization'
|
|
183
|
+
? {
|
|
184
|
+
clientIdEnv: exports.LINKEDIN_OAUTH_CONFIG.orgClientIdEnv,
|
|
185
|
+
clientSecretEnv: exports.LINKEDIN_OAUTH_CONFIG.orgClientSecretEnv,
|
|
186
|
+
scopes: resolveOrgScopes,
|
|
187
|
+
}
|
|
188
|
+
: {
|
|
189
|
+
clientIdEnv: exports.LINKEDIN_OAUTH_CONFIG.clientIdEnv,
|
|
190
|
+
clientSecretEnv: exports.LINKEDIN_OAUTH_CONFIG.clientSecretEnv,
|
|
191
|
+
scopes: resolveDefaultScopes,
|
|
192
|
+
};
|
|
193
|
+
}
|
|
194
|
+
/** Trocea una lista de scopes escrita a mano: coma, espacio o salto. */
|
|
195
|
+
function parseScopeList(raw) {
|
|
196
|
+
if (!raw)
|
|
197
|
+
return [];
|
|
198
|
+
return raw
|
|
199
|
+
.split(/[\s,]+/)
|
|
200
|
+
.map((s) => s.trim())
|
|
201
|
+
.filter(Boolean);
|
|
202
|
+
}
|
|
203
|
+
function dedupe(items) {
|
|
204
|
+
return [...new Set(items)];
|
|
205
|
+
}
|
|
206
|
+
function authorKindOf(urn) {
|
|
207
|
+
if (!urn)
|
|
208
|
+
return null;
|
|
209
|
+
if (urn.startsWith('urn:li:person:'))
|
|
210
|
+
return 'person';
|
|
211
|
+
if (urn.startsWith('urn:li:organization:'))
|
|
212
|
+
return 'organization';
|
|
213
|
+
// `urn:li:organizationBrand:` es una pagina de producto: LinkedIn la trata
|
|
214
|
+
// como organizacion en los mismos endpoints, asi que cuenta igual.
|
|
215
|
+
if (urn.startsWith('urn:li:organizationBrand:'))
|
|
216
|
+
return 'organization';
|
|
217
|
+
return null;
|
|
218
|
+
}
|
|
219
|
+
/**
|
|
220
|
+
* Los elementos de `organizationAcls` convertidos en paginas.
|
|
221
|
+
*
|
|
222
|
+
* LinkedIn devuelve el URN en `organization` y, cuando la proyeccion cuela,
|
|
223
|
+
* los datos decorados en `organization~`. Se toleran las dos formas porque la
|
|
224
|
+
* decoracion es lo primero que deja de venir cuando cambia la version de la
|
|
225
|
+
* API, y perder el nombre no puede costar la pagina entera: un desplegable que
|
|
226
|
+
* dice `urn:li:organization:123` es feo, pero elegible; uno vacio no.
|
|
227
|
+
*/
|
|
228
|
+
function parseOrganizationAcls(elements) {
|
|
229
|
+
const out = [];
|
|
230
|
+
const vistos = new Set();
|
|
231
|
+
for (const el of elements ?? []) {
|
|
232
|
+
const urn = typeof el?.organization === 'string' ? el.organization : '';
|
|
233
|
+
if (!urn || vistos.has(urn))
|
|
234
|
+
continue;
|
|
235
|
+
vistos.add(urn);
|
|
236
|
+
const decorado = el['organization~'];
|
|
237
|
+
const name = typeof decorado?.localizedName === 'string' && decorado.localizedName
|
|
238
|
+
? decorado.localizedName
|
|
239
|
+
: urn;
|
|
240
|
+
const vanityName = typeof decorado?.vanityName === 'string'
|
|
241
|
+
? decorado.vanityName
|
|
242
|
+
: undefined;
|
|
243
|
+
const role = typeof el.role === 'string' ? el.role : undefined;
|
|
244
|
+
out.push({
|
|
245
|
+
urn,
|
|
246
|
+
name,
|
|
247
|
+
...(vanityName ? { vanityName } : {}),
|
|
248
|
+
...(role ? { role } : {}),
|
|
249
|
+
});
|
|
250
|
+
}
|
|
251
|
+
return out;
|
|
252
|
+
}
|
|
253
|
+
/**
|
|
254
|
+
* La persona de una credencial, o cadena vacia si no la hay.
|
|
255
|
+
*
|
|
256
|
+
* Las credenciales anteriores a las paginas no guardan `personUrn`: entonces
|
|
257
|
+
* `authorUrn` solo podia ser la persona, asi que ese es el relleno correcto —
|
|
258
|
+
* PERO solo si de verdad es un URN de persona. Cayendo a `authorUrn` a secas,
|
|
259
|
+
* una credencial de la app de pagina devolveria el URN de la pagina como si
|
|
260
|
+
* fuera su persona, y `setAuthor` la aceptaria como identidad «personal».
|
|
261
|
+
*/
|
|
262
|
+
function personUrnOf(payload) {
|
|
263
|
+
const person = payload.personUrn;
|
|
264
|
+
if (typeof person === 'string' && person)
|
|
265
|
+
return person;
|
|
266
|
+
const author = payload.authorUrn;
|
|
267
|
+
if (typeof author === 'string' && authorKindOf(author) === 'person') {
|
|
268
|
+
return author;
|
|
269
|
+
}
|
|
270
|
+
return '';
|
|
271
|
+
}
|
|
272
|
+
function toOrganizations(raw) {
|
|
273
|
+
if (!Array.isArray(raw))
|
|
274
|
+
return [];
|
|
275
|
+
return raw.filter((o) => !!o &&
|
|
276
|
+
typeof o === 'object' &&
|
|
277
|
+
typeof o.urn === 'string');
|
|
278
|
+
}
|
|
279
|
+
/**
|
|
280
|
+
* Por que ESTA credencial no puede sondear, o `null` si puede.
|
|
281
|
+
*
|
|
282
|
+
* Un solo sitio lo decide, y lo consultan los tres que opinan: la compuerta de
|
|
283
|
+
* activacion del trigger, el provider antes de gastar una llamada, y la
|
|
284
|
+
* pantalla para avisar antes de guardar.
|
|
285
|
+
*
|
|
286
|
+
* ## La regla del silencio
|
|
287
|
+
*
|
|
288
|
+
* Con `scopes` vacio o ausente NO se bloquea. Las credenciales anteriores a
|
|
289
|
+
* esto guardan la lista, pero una que no la tenga no es prueba de que falte el
|
|
290
|
+
* permiso — es prueba de que no lo sabemos, y bloquear por no saber convierte
|
|
291
|
+
* un fallo nuestro en un «no se puede» del usuario. Se bloquea solo cuando
|
|
292
|
+
* consta que el scope NO fue concedido.
|
|
293
|
+
*/
|
|
294
|
+
function pollBlockReason(params) {
|
|
295
|
+
const kind = authorKindOf(params.authorUrn);
|
|
296
|
+
const scopes = params.scopes ?? [];
|
|
297
|
+
// Sin lista de scopes no se juzga — ver «la regla del silencio».
|
|
298
|
+
if (scopes.length === 0)
|
|
299
|
+
return null;
|
|
300
|
+
if (kind === 'organization') {
|
|
301
|
+
if (scopes.includes(exports.LINKEDIN_ORG_READ_SCOPE))
|
|
302
|
+
return null;
|
|
303
|
+
return (`This LinkedIn credential points at a Company Page but was connected without ` +
|
|
304
|
+
`the "${exports.LINKEDIN_ORG_READ_SCOPE}" permission, so its posts cannot be read. ` +
|
|
305
|
+
`Request the Community Management API product for the LinkedIn app, then ` +
|
|
306
|
+
`reconnect the credential to grant it.`);
|
|
307
|
+
}
|
|
308
|
+
if (kind === 'person') {
|
|
309
|
+
if (scopes.includes(exports.LINKEDIN_MEMBER_READ_SCOPE))
|
|
310
|
+
return null;
|
|
311
|
+
return (`LinkedIn does not let apps read a personal profile's own posts — ` +
|
|
312
|
+
`"${exports.LINKEDIN_MEMBER_READ_SCOPE}" is restricted and LinkedIn is not granting it. ` +
|
|
313
|
+
`Point this credential at a Company Page you administer (Settings → ` +
|
|
314
|
+
`Credentials → the LinkedIn credential → "Publish and poll as"), or use the ` +
|
|
315
|
+
`credential for posting only.`);
|
|
316
|
+
}
|
|
317
|
+
// URN irreconocible: no inventamos un motivo. El fallo saldra en la llamada.
|
|
318
|
+
return null;
|
|
319
|
+
}
|
|
320
|
+
/**
|
|
321
|
+
* Por que ESTA credencial no puede publicar, o `null` si puede.
|
|
322
|
+
*
|
|
323
|
+
* Misma regla del silencio que `pollBlockReason`. Publicar como persona es el
|
|
324
|
+
* caso gratuito; como pagina hace falta Community Management.
|
|
325
|
+
*/
|
|
326
|
+
function postBlockReason(params) {
|
|
327
|
+
const kind = authorKindOf(params.authorUrn);
|
|
328
|
+
const scopes = params.scopes ?? [];
|
|
329
|
+
if (scopes.length === 0)
|
|
330
|
+
return null;
|
|
331
|
+
if (kind === 'organization' && !scopes.includes(exports.LINKEDIN_ORG_WRITE_SCOPE)) {
|
|
332
|
+
return (`This LinkedIn credential points at a Company Page but was connected without ` +
|
|
333
|
+
`the "${exports.LINKEDIN_ORG_WRITE_SCOPE}" permission, so it cannot post as the page. ` +
|
|
334
|
+
`Request the Community Management API product for the LinkedIn app, then ` +
|
|
335
|
+
`reconnect the credential.`);
|
|
336
|
+
}
|
|
337
|
+
return null;
|
|
338
|
+
}
|
|
@@ -0,0 +1,135 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Mailchimp OAuth 2.0 configuration for HostWebhook.
|
|
3
|
+
*
|
|
4
|
+
* Plain OAuth 2.0 authorization-code flow with a confidential client — one
|
|
5
|
+
* HW-wide registered app, credentials from env vars. Register it at
|
|
6
|
+
* <https://us1.admin.mailchimp.com/account/oauth2/> ("Registered Apps" in the
|
|
7
|
+
* Mailchimp account menu), with the redirect URL set to
|
|
8
|
+
* `{API_URL}/api/credentials/mailchimp/oauth/callback`.
|
|
9
|
+
*
|
|
10
|
+
* ## Three things Mailchimp does NOT have, and none of them are oversights
|
|
11
|
+
*
|
|
12
|
+
* **No scopes.** The authorize URL takes `response_type`, `client_id`,
|
|
13
|
+
* `redirect_uri` and `state`, and nothing else. There is no `scope` parameter
|
|
14
|
+
* and no scope list to keep in sync — a token grants full access to the
|
|
15
|
+
* account that issued it. Do not add a `defaultScopes` here to match the other
|
|
16
|
+
* providers: it would be sent, ignored, and then read back by whoever
|
|
17
|
+
* maintains this next as if it meant something.
|
|
18
|
+
*
|
|
19
|
+
* **No expiry and no refresh token.** Mailchimp's own guide: "Mailchimp
|
|
20
|
+
* Marketing access tokens do not expire, so you don't need to use a
|
|
21
|
+
* `refresh_token`. The access token will remain valid unless the user revokes
|
|
22
|
+
* your application's permission." So there is no expiry timer to run and no
|
|
23
|
+
* `oauthExpiresAt` to write — a credential is live until the user revokes it.
|
|
24
|
+
*
|
|
25
|
+
* **No fixed API host.** Every account lives in a data centre, and the prefix
|
|
26
|
+
* is part of the base URL: `https://{dc}.api.mailchimp.com/3.0`. It is not
|
|
27
|
+
* inside the token and cannot be guessed. It comes from the metadata endpoint
|
|
28
|
+
* below, and it has to be persisted alongside the token — a stored Mailchimp
|
|
29
|
+
* token without its `dc` is unusable.
|
|
30
|
+
*
|
|
31
|
+
* See `docs/ADR-0003-mailchimp.md` for why this provider is OAuth-only.
|
|
32
|
+
*/
|
|
33
|
+
export declare const MAILCHIMP_OAUTH_CONFIG: {
|
|
34
|
+
readonly authorizationEndpoint: "https://login.mailchimp.com/oauth2/authorize";
|
|
35
|
+
readonly tokenEndpoint: "https://login.mailchimp.com/oauth2/token";
|
|
36
|
+
/**
|
|
37
|
+
* Returns the account's data centre. Called with `Authorization: OAuth
|
|
38
|
+
* {token}` — note `OAuth`, not `Bearer`; this is the one endpoint in the
|
|
39
|
+
* flow that does not take a Bearer header.
|
|
40
|
+
*/
|
|
41
|
+
readonly metadataEndpoint: "https://login.mailchimp.com/oauth2/metadata";
|
|
42
|
+
/** Env var names holding HW's registered client credentials. */
|
|
43
|
+
readonly clientIdEnv: "MAILCHIMP_OAUTH_CLIENT_ID";
|
|
44
|
+
readonly clientSecretEnv: "MAILCHIMP_OAUTH_CLIENT_SECRET";
|
|
45
|
+
};
|
|
46
|
+
/**
|
|
47
|
+
* What `/oauth2/metadata` gives back.
|
|
48
|
+
*
|
|
49
|
+
* **Only `dc` is documented.** The guide's sample code destructures exactly
|
|
50
|
+
* that one field. The rest show up in real responses and are worth keeping for
|
|
51
|
+
* the credential's display name, but every one of them is optional here on
|
|
52
|
+
* purpose: treating an undocumented field as required is how this breaks on a
|
|
53
|
+
* response shape we never agreed to.
|
|
54
|
+
*/
|
|
55
|
+
export interface MailchimpOAuthMetadata {
|
|
56
|
+
/** Data centre / server prefix — `us6`, `us21`, … The only required field. */
|
|
57
|
+
dc: string;
|
|
58
|
+
/** Account display name, e.g. "Freddie's Bakery". */
|
|
59
|
+
accountname?: string;
|
|
60
|
+
/** `owner`, `admin`, `manager`, `author`, `viewer`. */
|
|
61
|
+
role?: string;
|
|
62
|
+
/** Convenience base the metadata sometimes returns, e.g. `https://us6.api.mailchimp.com`. */
|
|
63
|
+
api_endpoint?: string;
|
|
64
|
+
login?: {
|
|
65
|
+
email?: string;
|
|
66
|
+
login_name?: string;
|
|
67
|
+
login_email?: string;
|
|
68
|
+
};
|
|
69
|
+
}
|
|
70
|
+
export interface MailchimpOAuthStoredAuth {
|
|
71
|
+
accessToken: string;
|
|
72
|
+
/** Server prefix. Without this there is no base URL — see the note above. */
|
|
73
|
+
dc: string;
|
|
74
|
+
/** Best-effort, for the credential list. Falls back to the account email. */
|
|
75
|
+
accountName: string;
|
|
76
|
+
email?: string;
|
|
77
|
+
role?: string;
|
|
78
|
+
/** Whether `GET /ping` answered when the credential was connected. */
|
|
79
|
+
pingOk: boolean;
|
|
80
|
+
}
|
|
81
|
+
export interface MailchimpOAuthStoredData {
|
|
82
|
+
accountName: string;
|
|
83
|
+
email?: string;
|
|
84
|
+
oauth: MailchimpOAuthStoredAuth;
|
|
85
|
+
}
|
|
86
|
+
export interface PendingMailchimpOAuthState {
|
|
87
|
+
orgId: string;
|
|
88
|
+
userId: string;
|
|
89
|
+
credentialName: string;
|
|
90
|
+
/** Set on reconnect to update an existing credential instead of creating one. */
|
|
91
|
+
credentialId?: string;
|
|
92
|
+
tags?: string[];
|
|
93
|
+
/** Destination folder chosen in the form. Travels through Redis between
|
|
94
|
+
* `start` and the callback; without it the choice was lost on the way. */
|
|
95
|
+
folderId?: string;
|
|
96
|
+
clientId: string;
|
|
97
|
+
clientSecret: string;
|
|
98
|
+
/** Absolute ms timestamp when this pending state expires. */
|
|
99
|
+
expiresAt: number;
|
|
100
|
+
}
|
|
101
|
+
export interface MailchimpTokenResponse {
|
|
102
|
+
access_token?: string;
|
|
103
|
+
/** Mailchimp sends `0` here. It does not mean "already expired" — it means
|
|
104
|
+
* the concept does not apply. Never feed this into an expiry calculation. */
|
|
105
|
+
expires_in?: number;
|
|
106
|
+
scope?: string | null;
|
|
107
|
+
error?: string;
|
|
108
|
+
error_description?: string;
|
|
109
|
+
}
|
|
110
|
+
/** The credential `type` string. */
|
|
111
|
+
export declare const MAILCHIMP_CREDENTIAL_TYPE = "mailchimp_oauth2";
|
|
112
|
+
/**
|
|
113
|
+
* Base URL for every Marketing API call made with this credential.
|
|
114
|
+
*
|
|
115
|
+
* Lives here rather than being interpolated at each call site so there is one
|
|
116
|
+
* place that knows the shape, and so a credential missing its `dc` fails
|
|
117
|
+
* loudly here instead of producing a request to `https://undefined.api…`.
|
|
118
|
+
*/
|
|
119
|
+
export declare function mailchimpApiBase(dc: string): string;
|
|
120
|
+
/**
|
|
121
|
+
* The authorize URL. No `scope` parameter — see the header.
|
|
122
|
+
*/
|
|
123
|
+
export declare function buildMailchimpAuthorizeUrl(params: {
|
|
124
|
+
clientId: string;
|
|
125
|
+
redirectUri: string;
|
|
126
|
+
state: string;
|
|
127
|
+
}): string;
|
|
128
|
+
/**
|
|
129
|
+
* The name shown in the credential list.
|
|
130
|
+
*
|
|
131
|
+
* Prefers the account name, falls back to the login email, and only then to
|
|
132
|
+
* the data centre — which is at least true and unique-ish, and beats an empty
|
|
133
|
+
* row that gives the user nothing to recognise.
|
|
134
|
+
*/
|
|
135
|
+
export declare function mailchimpDisplayName(meta: MailchimpOAuthMetadata): string;
|
|
@@ -0,0 +1,96 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
/**
|
|
3
|
+
* Mailchimp OAuth 2.0 configuration for HostWebhook.
|
|
4
|
+
*
|
|
5
|
+
* Plain OAuth 2.0 authorization-code flow with a confidential client — one
|
|
6
|
+
* HW-wide registered app, credentials from env vars. Register it at
|
|
7
|
+
* <https://us1.admin.mailchimp.com/account/oauth2/> ("Registered Apps" in the
|
|
8
|
+
* Mailchimp account menu), with the redirect URL set to
|
|
9
|
+
* `{API_URL}/api/credentials/mailchimp/oauth/callback`.
|
|
10
|
+
*
|
|
11
|
+
* ## Three things Mailchimp does NOT have, and none of them are oversights
|
|
12
|
+
*
|
|
13
|
+
* **No scopes.** The authorize URL takes `response_type`, `client_id`,
|
|
14
|
+
* `redirect_uri` and `state`, and nothing else. There is no `scope` parameter
|
|
15
|
+
* and no scope list to keep in sync — a token grants full access to the
|
|
16
|
+
* account that issued it. Do not add a `defaultScopes` here to match the other
|
|
17
|
+
* providers: it would be sent, ignored, and then read back by whoever
|
|
18
|
+
* maintains this next as if it meant something.
|
|
19
|
+
*
|
|
20
|
+
* **No expiry and no refresh token.** Mailchimp's own guide: "Mailchimp
|
|
21
|
+
* Marketing access tokens do not expire, so you don't need to use a
|
|
22
|
+
* `refresh_token`. The access token will remain valid unless the user revokes
|
|
23
|
+
* your application's permission." So there is no expiry timer to run and no
|
|
24
|
+
* `oauthExpiresAt` to write — a credential is live until the user revokes it.
|
|
25
|
+
*
|
|
26
|
+
* **No fixed API host.** Every account lives in a data centre, and the prefix
|
|
27
|
+
* is part of the base URL: `https://{dc}.api.mailchimp.com/3.0`. It is not
|
|
28
|
+
* inside the token and cannot be guessed. It comes from the metadata endpoint
|
|
29
|
+
* below, and it has to be persisted alongside the token — a stored Mailchimp
|
|
30
|
+
* token without its `dc` is unusable.
|
|
31
|
+
*
|
|
32
|
+
* See `docs/ADR-0003-mailchimp.md` for why this provider is OAuth-only.
|
|
33
|
+
*/
|
|
34
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
35
|
+
exports.MAILCHIMP_CREDENTIAL_TYPE = exports.MAILCHIMP_OAUTH_CONFIG = void 0;
|
|
36
|
+
exports.mailchimpApiBase = mailchimpApiBase;
|
|
37
|
+
exports.buildMailchimpAuthorizeUrl = buildMailchimpAuthorizeUrl;
|
|
38
|
+
exports.mailchimpDisplayName = mailchimpDisplayName;
|
|
39
|
+
exports.MAILCHIMP_OAUTH_CONFIG = {
|
|
40
|
+
authorizationEndpoint: 'https://login.mailchimp.com/oauth2/authorize',
|
|
41
|
+
tokenEndpoint: 'https://login.mailchimp.com/oauth2/token',
|
|
42
|
+
/**
|
|
43
|
+
* Returns the account's data centre. Called with `Authorization: OAuth
|
|
44
|
+
* {token}` — note `OAuth`, not `Bearer`; this is the one endpoint in the
|
|
45
|
+
* flow that does not take a Bearer header.
|
|
46
|
+
*/
|
|
47
|
+
metadataEndpoint: 'https://login.mailchimp.com/oauth2/metadata',
|
|
48
|
+
/** Env var names holding HW's registered client credentials. */
|
|
49
|
+
clientIdEnv: 'MAILCHIMP_OAUTH_CLIENT_ID',
|
|
50
|
+
clientSecretEnv: 'MAILCHIMP_OAUTH_CLIENT_SECRET',
|
|
51
|
+
};
|
|
52
|
+
/** The credential `type` string. */
|
|
53
|
+
exports.MAILCHIMP_CREDENTIAL_TYPE = 'mailchimp_oauth2';
|
|
54
|
+
/**
|
|
55
|
+
* Base URL for every Marketing API call made with this credential.
|
|
56
|
+
*
|
|
57
|
+
* Lives here rather than being interpolated at each call site so there is one
|
|
58
|
+
* place that knows the shape, and so a credential missing its `dc` fails
|
|
59
|
+
* loudly here instead of producing a request to `https://undefined.api…`.
|
|
60
|
+
*/
|
|
61
|
+
function mailchimpApiBase(dc) {
|
|
62
|
+
const prefix = String(dc ?? '').trim();
|
|
63
|
+
if (!/^[a-z]{2}\d{1,3}$/i.test(prefix)) {
|
|
64
|
+
throw new Error(`Mailchimp credential has no usable data centre prefix (got "${dc}"). ` +
|
|
65
|
+
`Reconnect the credential — the prefix is captured at connect time.`);
|
|
66
|
+
}
|
|
67
|
+
return `https://${prefix.toLowerCase()}.api.mailchimp.com/3.0`;
|
|
68
|
+
}
|
|
69
|
+
/**
|
|
70
|
+
* The authorize URL. No `scope` parameter — see the header.
|
|
71
|
+
*/
|
|
72
|
+
function buildMailchimpAuthorizeUrl(params) {
|
|
73
|
+
const qs = new URLSearchParams({
|
|
74
|
+
response_type: 'code',
|
|
75
|
+
client_id: params.clientId,
|
|
76
|
+
redirect_uri: params.redirectUri,
|
|
77
|
+
state: params.state,
|
|
78
|
+
});
|
|
79
|
+
return `${exports.MAILCHIMP_OAUTH_CONFIG.authorizationEndpoint}?${qs.toString()}`;
|
|
80
|
+
}
|
|
81
|
+
/**
|
|
82
|
+
* The name shown in the credential list.
|
|
83
|
+
*
|
|
84
|
+
* Prefers the account name, falls back to the login email, and only then to
|
|
85
|
+
* the data centre — which is at least true and unique-ish, and beats an empty
|
|
86
|
+
* row that gives the user nothing to recognise.
|
|
87
|
+
*/
|
|
88
|
+
function mailchimpDisplayName(meta) {
|
|
89
|
+
const account = meta.accountname?.trim();
|
|
90
|
+
if (account)
|
|
91
|
+
return account;
|
|
92
|
+
const email = (meta.login?.email ?? meta.login?.login_email)?.trim();
|
|
93
|
+
if (email)
|
|
94
|
+
return email;
|
|
95
|
+
return `Mailchimp (${meta.dc})`;
|
|
96
|
+
}
|