@hostwebhook/platform-node 0.2.0 → 0.4.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.
@@ -0,0 +1,314 @@
1
+ /**
2
+ * LinkedIn OAuth 2.0 configuration for HostWebhook.
3
+ *
4
+ * LinkedIn's auth model is plain OAuth 2.0 (NOT PKCE) with a
5
+ * confidential client. No per-instance dance like Mastodon — single
6
+ * HW-wide registered app, env-var-driven client credentials.
7
+ *
8
+ * ## SON DOS APPS DE LINKEDIN, no una
9
+ *
10
+ * LinkedIn exige que **Community Management API sea el unico producto** de su
11
+ * aplicacion, «due to legal and security constraints». En una app que ya tenga
12
+ * Share o Sign In with LinkedIn, la opcion de pedirlo sale en gris; y donde ya
13
+ * esta concedido, activar OIDC lo deshabilita. No es un fallo de configuracion
14
+ * ni algo que se arregle insistiendo: son dos aplicaciones.
15
+ *
16
+ * App MIEMBRO — https://www.linkedin.com/developers/apps
17
+ * Products: "Sign In with LinkedIn using OpenID Connect" + "Share on
18
+ * LinkedIn". Gratuitas y automaticas.
19
+ * Da: openid, profile, email, w_member_social.
20
+ * Sirve para: publicar en un perfil personal.
21
+ * Env: LINKEDIN_OAUTH_CLIENT_ID / LINKEDIN_OAUTH_CLIENT_SECRET.
22
+ *
23
+ * App PAGINA — otra aplicacion, creada limpia y SIN anadirle ningun otro
24
+ * producto antes de pedirlo.
25
+ * Products: "Community Management API", y nada mas. Pasa por revision.
26
+ * Da: r_organization_social, w_organization_social, rw_organization_admin.
27
+ * Sirve para: publicar como pagina Y SONDEAR sus posts (lo unico que
28
+ * desbloquea el trigger).
29
+ * Env: LINKEDIN_ORG_OAUTH_CLIENT_ID / LINKEDIN_ORG_OAUTH_CLIENT_SECRET.
30
+ *
31
+ * Las dos registran el MISMO redirect: `{API_URL}/api/credentials/linkedin/
32
+ * oauth/callback`. No hay ambiguedad al volver porque el `state` guardado en
33
+ * Redis dice de que app salio, y con el la pareja de credenciales correcta.
34
+ *
35
+ * ## Lo que NO tiene la app de pagina
36
+ *
37
+ * OIDC. Ahi no hay `openid`, asi que `/v2/userinfo` no se puede llamar y esa
38
+ * conexion no conoce a la persona: se identifica por las paginas que
39
+ * administra (`organizationAcls`). De ahi que `memberId` y `personUrn` sean
40
+ * opcionales — ver `LinkedInOAuthStoredAuth`.
41
+ *
42
+ * Token lifetime: 60 days (default). Refresh tokens optional and
43
+ * issued only when LinkedIn deems necessary — most HW deployments
44
+ * just persist the access token until expiry, then prompt reconnect.
45
+ *
46
+ * ## Las dos identidades
47
+ *
48
+ * Una credencial de LinkedIn representa **a quien se publica y a quien se
49
+ * sondea**, y eso puede ser una persona (`urn:li:person:{sub}`) o una pagina
50
+ * de empresa (`urn:li:organization:{id}`). Todo el provider ya trabajaba con
51
+ * un solo `authorUrn`, asi que la eleccion vive en la credencial y ni el nodo
52
+ * ni el trigger tienen que enterarse: cambiar de identidad es cambiar ese URN.
53
+ *
54
+ * La diferencia que importa esta en la LECTURA, no en la escritura:
55
+ *
56
+ * - publicar como persona -> `w_member_social`, producto gratuito.
57
+ * - publicar como pagina -> `w_organization_social`, Community Management.
58
+ * - LEER los posts propios de una persona -> `r_member_social`, que LinkedIn
59
+ * describe como «restricted and available to approved users only» y no
60
+ * esta concediendo. Por eso el trigger sobre un perfil personal no puede
61
+ * funcionar por mucho que la app este bien montada.
62
+ * - LEER los posts de una pagina -> `r_organization_social`, y ese SI se
63
+ * concede via Community Management API.
64
+ *
65
+ * De ahi que el trigger sea una funcion de la identidad elegida y de los
66
+ * scopes concedidos, no de una tabla fija: si LinkedIn cambiara de criterio y
67
+ * concediera `r_member_social`, el perfil personal empieza a sondear solo, sin
68
+ * tocar una linea. Ver `pollBlockReason`.
69
+ */
70
+ export declare const LINKEDIN_OAUTH_CONFIG: {
71
+ readonly authorizationEndpoint: "https://www.linkedin.com/oauth/v2/authorization";
72
+ readonly tokenEndpoint: "https://www.linkedin.com/oauth/v2/accessToken";
73
+ /** OpenID Connect userinfo — returns sub (member id), name, email, etc. */
74
+ readonly userInfoWebhook: "https://api.linkedin.com/v2/userinfo";
75
+ /** Env var names holding HW's registered client credentials. */
76
+ readonly clientIdEnv: "LINKEDIN_OAUTH_CLIENT_ID";
77
+ readonly clientSecretEnv: "LINKEDIN_OAUTH_CLIENT_SECRET";
78
+ /**
79
+ * Scopes extra que este despliegue puede pedir, separados por coma.
80
+ *
81
+ * ## Por que es una variable y no una constante
82
+ *
83
+ * LinkedIn rechaza la autorizacion ENTERA con `unauthorized_scope_error` si
84
+ * pides un scope que la app no tiene habilitado — no lo ignora. Meter los
85
+ * scopes de pagina en la lista fija romperia el conector personal, que hoy
86
+ * funciona, en todos los despliegues cuya app aun no tenga aprobado
87
+ * Community Management API.
88
+ *
89
+ * Asi que la lista base es la que garantizan los dos productos gratuitos, y
90
+ * lo que dependa de una aprobacion se enciende aqui cuando LinkedIn la
91
+ * concede:
92
+ *
93
+ * LINKEDIN_OAUTH_EXTRA_SCOPES=r_organization_social,w_organization_social,rw_organization_admin
94
+ *
95
+ * Es una variable y no un booleano a proposito: LinkedIn renombra y trocea
96
+ * sus permisos cada pocos meses, y asi ajustarlo no pide un despliegue de
97
+ * codigo.
98
+ */
99
+ readonly extraScopesEnv: "LINKEDIN_OAUTH_EXTRA_SCOPES";
100
+ /** Default scope set:
101
+ * - openid profile email — OIDC identity (always required)
102
+ * - w_member_social — post on behalf of the authenticated member
103
+ *
104
+ * `w_member_social` is "Share on LinkedIn" product scope — must be
105
+ * enabled on the LinkedIn app via the Products tab. */
106
+ readonly defaultScopes: readonly ["openid", "profile", "email", "w_member_social"];
107
+ /** Credenciales de la SEGUNDA app, la de Community Management API. */
108
+ readonly orgClientIdEnv: "LINKEDIN_ORG_OAUTH_CLIENT_ID";
109
+ readonly orgClientSecretEnv: "LINKEDIN_ORG_OAUTH_CLIENT_SECRET";
110
+ /** Sobrescribe los scopes de la app de pagina sin desplegar codigo. */
111
+ readonly orgScopesEnv: "LINKEDIN_ORG_OAUTH_SCOPES";
112
+ /**
113
+ * Lo que concede Community Management API en su nivel de desarrollo:
114
+ * - r_organization_social — leer los posts de la pagina (EL TRIGGER)
115
+ * - w_organization_social — publicar como la pagina
116
+ * - rw_organization_admin — listar que paginas administra el miembro
117
+ *
118
+ * Sin `openid` a proposito: esa app no tiene OIDC y pedirlo la tumbaria
119
+ * entera con `unauthorized_scope_error`.
120
+ */
121
+ readonly orgDefaultScopes: readonly ["r_organization_social", "w_organization_social", "rw_organization_admin"];
122
+ };
123
+ /**
124
+ * De cual de las dos apps sale una conexion.
125
+ *
126
+ * Viaja en el `state` de Redis entre el `start` y el callback, y queda
127
+ * guardado en la credencial: un reconnect tiene que volver por la MISMA app,
128
+ * porque la otra ni tiene sus permisos ni reconoce su client_id.
129
+ */
130
+ export type LinkedInAppKind = 'member' | 'organization';
131
+ /**
132
+ * La version de la API de marketing que se declara en cada llamada a `/rest`.
133
+ *
134
+ * Iba escrita en el provider, y ahora la necesitan dos sitios: el provider y el
135
+ * descubrimiento de paginas del OAuth. Una sola copia, porque LinkedIn RETIRA
136
+ * las versiones viejas cada pocos meses («The Marketing Version 202507 has been
137
+ * sunset») y en `/rest` una version retirada no es un aviso, es un fallo. Con
138
+ * dos copias, subir una y olvidar la otra deja media integracion rota.
139
+ *
140
+ * Los `/v2` no la consultan — de ahi que el desfase no diera la cara.
141
+ */
142
+ export declare const LINKEDIN_REST_VERSION = "202607";
143
+ /** Leer los posts propios de una PERSONA. Restringido por LinkedIn. */
144
+ export declare const LINKEDIN_MEMBER_READ_SCOPE = "r_member_social";
145
+ /** Leer los posts de una PAGINA. Community Management API. */
146
+ export declare const LINKEDIN_ORG_READ_SCOPE = "r_organization_social";
147
+ /** Publicar como PAGINA. Community Management API. */
148
+ export declare const LINKEDIN_ORG_WRITE_SCOPE = "w_organization_social";
149
+ /** Listar las paginas que administra el miembro (`organizationAcls`). */
150
+ export declare const LINKEDIN_ORG_ADMIN_SCOPE = "rw_organization_admin";
151
+ /**
152
+ * Los scopes que se piden en la autorizacion: los base mas los que este
153
+ * despliegue haya encendido por entorno.
154
+ *
155
+ * `env` se inyecta para que la prueba no tenga que tocar `process.env`.
156
+ */
157
+ export declare function resolveDefaultScopes(env?: NodeJS.ProcessEnv): string[];
158
+ /** Lo que se le pide a la app de pagina. Sobrescribible por entorno. */
159
+ export declare function resolveOrgScopes(env?: NodeJS.ProcessEnv): string[];
160
+ /** Los scopes y los nombres de variable que le tocan a cada app. */
161
+ export declare function appConfigFor(kind: LinkedInAppKind): {
162
+ clientIdEnv: string;
163
+ clientSecretEnv: string;
164
+ scopes: (env?: NodeJS.ProcessEnv) => string[];
165
+ };
166
+ /** Trocea una lista de scopes escrita a mano: coma, espacio o salto. */
167
+ export declare function parseScopeList(raw: string | undefined | null): string[];
168
+ /** Que clase de autor es un URN. `null` cuando no se reconoce. */
169
+ export type LinkedInAuthorKind = 'person' | 'organization';
170
+ export declare function authorKindOf(urn: string | undefined | null): LinkedInAuthorKind | null;
171
+ /** Una pagina de empresa que el miembro administra. */
172
+ export interface LinkedInOrganization {
173
+ /** `urn:li:organization:{id}` — lo que se guarda como `authorUrn`. */
174
+ urn: string;
175
+ /** Nombre visible. Cae al URN cuando LinkedIn no lo devuelve. */
176
+ name: string;
177
+ /** `vanityName` — el trozo de linkedin.com/company/{vanityName}. */
178
+ vanityName?: string;
179
+ /** El rol con el que figura el miembro (ADMINISTRATOR, etc.). */
180
+ role?: string;
181
+ }
182
+ /**
183
+ * Los elementos de `organizationAcls` convertidos en paginas.
184
+ *
185
+ * LinkedIn devuelve el URN en `organization` y, cuando la proyeccion cuela,
186
+ * los datos decorados en `organization~`. Se toleran las dos formas porque la
187
+ * decoracion es lo primero que deja de venir cuando cambia la version de la
188
+ * API, y perder el nombre no puede costar la pagina entera: un desplegable que
189
+ * dice `urn:li:organization:123` es feo, pero elegible; uno vacio no.
190
+ */
191
+ export declare function parseOrganizationAcls(elements: Array<Record<string, unknown>>): LinkedInOrganization[];
192
+ /**
193
+ * La persona de una credencial, o cadena vacia si no la hay.
194
+ *
195
+ * Las credenciales anteriores a las paginas no guardan `personUrn`: entonces
196
+ * `authorUrn` solo podia ser la persona, asi que ese es el relleno correcto —
197
+ * PERO solo si de verdad es un URN de persona. Cayendo a `authorUrn` a secas,
198
+ * una credencial de la app de pagina devolveria el URN de la pagina como si
199
+ * fuera su persona, y `setAuthor` la aceptaria como identidad «personal».
200
+ */
201
+ export declare function personUrnOf(payload: Record<string, unknown>): string;
202
+ export declare function toOrganizations(raw: unknown): LinkedInOrganization[];
203
+ /**
204
+ * Por que ESTA credencial no puede sondear, o `null` si puede.
205
+ *
206
+ * Un solo sitio lo decide, y lo consultan los tres que opinan: la compuerta de
207
+ * activacion del trigger, el provider antes de gastar una llamada, y la
208
+ * pantalla para avisar antes de guardar.
209
+ *
210
+ * ## La regla del silencio
211
+ *
212
+ * Con `scopes` vacio o ausente NO se bloquea. Las credenciales anteriores a
213
+ * esto guardan la lista, pero una que no la tenga no es prueba de que falte el
214
+ * permiso — es prueba de que no lo sabemos, y bloquear por no saber convierte
215
+ * un fallo nuestro en un «no se puede» del usuario. Se bloquea solo cuando
216
+ * consta que el scope NO fue concedido.
217
+ */
218
+ export declare function pollBlockReason(params: {
219
+ authorUrn?: string | null;
220
+ scopes?: string[] | null;
221
+ }): string | null;
222
+ /**
223
+ * Por que ESTA credencial no puede publicar, o `null` si puede.
224
+ *
225
+ * Misma regla del silencio que `pollBlockReason`. Publicar como persona es el
226
+ * caso gratuito; como pagina hace falta Community Management.
227
+ */
228
+ export declare function postBlockReason(params: {
229
+ authorUrn?: string | null;
230
+ scopes?: string[] | null;
231
+ }): string | null;
232
+ export interface LinkedInUserInfo {
233
+ /** OIDC `sub` — opaque LinkedIn member id. Used as the URN base
234
+ * (`urn:li:person:{sub}`) for posts via the UGC webhook. */
235
+ sub: string;
236
+ name: string;
237
+ given_name?: string;
238
+ family_name?: string;
239
+ email?: string;
240
+ picture?: string;
241
+ }
242
+ export interface LinkedInOAuthStoredAuth {
243
+ accessToken: string;
244
+ /** LinkedIn issues refresh tokens at its discretion. Optional. */
245
+ refreshToken?: string;
246
+ /** Absolute expiry in ms. Defaults to 60 days when LinkedIn returns
247
+ * `expires_in: 5183999`. */
248
+ expiresAt?: number;
249
+ tokenType: string;
250
+ scopes: string[];
251
+ /** De cual de las dos apps salio. Un reconnect vuelve por la misma. */
252
+ appKind: LinkedInAppKind;
253
+ /**
254
+ * `sub` from /v2/userinfo — the URN base.
255
+ *
256
+ * Opcional porque la app de PAGINA no tiene OIDC: ahi no hay `/v2/userinfo`
257
+ * que llamar y la conexion no conoce a la persona, solo sus paginas.
258
+ */
259
+ memberId?: string;
260
+ memberName?: string;
261
+ email?: string;
262
+ /**
263
+ * A quien se publica y a quien se sondea. Por defecto la persona; puede
264
+ * apuntar a una de las paginas de `organizations`.
265
+ *
266
+ * Es el unico campo que lee el provider, de ahi que cambiar de identidad no
267
+ * toque ni un nodo ni un trigger.
268
+ */
269
+ authorUrn: string;
270
+ /**
271
+ * La persona, cuando se sabe. `authorUrn` se puede mover a una pagina y hay
272
+ * que poder volver.
273
+ *
274
+ * Ausente en dos casos distintos: en una credencial anterior a las paginas
275
+ * (ahi `authorUrn` ERA la persona, y de eso se ocupa `personUrnOf`) y en una
276
+ * conexion por la app de pagina, que no tiene OIDC y nunca conoce a nadie.
277
+ */
278
+ personUrn?: string;
279
+ /** Las paginas que el miembro administra, descubiertas al conectar. */
280
+ organizations?: LinkedInOrganization[];
281
+ }
282
+ export interface LinkedInOAuthStoredData {
283
+ /** Cached display name + email at the top level for fast UI. */
284
+ memberName: string;
285
+ email?: string;
286
+ oauth: LinkedInOAuthStoredAuth;
287
+ }
288
+ export interface PendingLinkedInOAuthState {
289
+ orgId: string;
290
+ userId: string;
291
+ credentialName: string;
292
+ /** Optional — set on reconnect to update an existing credential. */
293
+ credentialId?: string;
294
+ tags?: string[];
295
+ /** Carpeta destino elegida en el formulario. Viaja por Redis entre el
296
+ * `start` y el callback: sin declararla aqui se perdia por el camino. */
297
+ folderId?: string;
298
+ clientId: string;
299
+ clientSecret: string;
300
+ requestedScopes: string[];
301
+ /** De que app salio. El callback lo necesita para saber si hay OIDC. */
302
+ appKind: LinkedInAppKind;
303
+ /** Absolute ms timestamp when this pending state expires (10 min). */
304
+ expiresAt: number;
305
+ }
306
+ export interface LinkedInTokenResponse {
307
+ access_token: string;
308
+ refresh_token?: string;
309
+ expires_in?: number;
310
+ scope?: string;
311
+ token_type?: string;
312
+ error?: string;
313
+ error_description?: string;
314
+ }
@@ -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
+ }