@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
package/dist/index.d.ts
CHANGED
|
@@ -4,3 +4,7 @@ export * from './mongo-databases';
|
|
|
4
4
|
export * from './postgres-databases';
|
|
5
5
|
export * from './busqueda-literal';
|
|
6
6
|
export * from './escapar-regex';
|
|
7
|
+
export * from './presets-de-oauth/github-oauth-presets';
|
|
8
|
+
export * from './presets-de-oauth/linkedin-oauth-presets';
|
|
9
|
+
export * from './presets-de-oauth/mailchimp-oauth-presets';
|
|
10
|
+
export * from './presets-de-oauth/shopify-oauth-presets';
|
package/dist/index.js
CHANGED
|
@@ -23,3 +23,10 @@ __exportStar(require("./mongo-databases"), exports);
|
|
|
23
23
|
__exportStar(require("./postgres-databases"), exports);
|
|
24
24
|
__exportStar(require("./busqueda-literal"), exports);
|
|
25
25
|
__exportStar(require("./escapar-regex"), exports);
|
|
26
|
+
/* Los presets de OAuth: datos puros, cero dependencias externas (shopify usa el
|
|
27
|
+
`crypto` de Node, que es incorporado). Los comparten la api, `nodes/` y los
|
|
28
|
+
ayudantes de `common/` que hablan con cada plataforma. */
|
|
29
|
+
__exportStar(require("./presets-de-oauth/github-oauth-presets"), exports);
|
|
30
|
+
__exportStar(require("./presets-de-oauth/linkedin-oauth-presets"), exports);
|
|
31
|
+
__exportStar(require("./presets-de-oauth/mailchimp-oauth-presets"), exports);
|
|
32
|
+
__exportStar(require("./presets-de-oauth/shopify-oauth-presets"), exports);
|
|
@@ -0,0 +1,199 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Constantes y formas del OAuth de HostWebhook contra GitHub.
|
|
3
|
+
*
|
|
4
|
+
* ── Qué se guarda, y por qué es distinto a los demás proveedores ──
|
|
5
|
+
*
|
|
6
|
+
* Casi todos los proveedores de esta carpeta guardan un `access_token` del
|
|
7
|
+
* usuario y lo refrescan. Aquí NO. Lo que se guarda es un `installationId`, y
|
|
8
|
+
* **ningún secreto**: cada llamada mina un token de instalación firmando un
|
|
9
|
+
* JWT con la private key de la app, que vive en el entorno.
|
|
10
|
+
*
|
|
11
|
+
* La razón no es estética. Un token de usuario muere cuando esa persona deja
|
|
12
|
+
* la organización, y una automatización que lleva meses corriendo se cae con
|
|
13
|
+
* ella. El token de instalación pertenece a la instalación, no a quien la hizo.
|
|
14
|
+
*
|
|
15
|
+
* Consecuencia práctica: aquí no hay nada que caduque en la base de datos, y
|
|
16
|
+
* el flujo de OAuth existe para dos cosas —comprobar que el usuario autorizó y
|
|
17
|
+
* quedarse con el `installation_id` que vuelve en el callback—, no para
|
|
18
|
+
* quedarse con su token.
|
|
19
|
+
*
|
|
20
|
+
* ── Contra qué docs está escrito (verificado 2026-08-25) ──
|
|
21
|
+
*
|
|
22
|
+
* - JWT: RS256; `iss` es el App ID (los docs admiten también el client id);
|
|
23
|
+
* `iat` **60 segundos en el pasado** por deriva de reloj; `exp` como mucho
|
|
24
|
+
* 10 minutos en el futuro.
|
|
25
|
+
* - Token de instalación: `POST /app/installations/{id}/access_tokens` con
|
|
26
|
+
* el JWT en `Authorization: Bearer`. Dura **1 hora**.
|
|
27
|
+
* - Token de usuario: 8 h (28800 s), refresh de 6 meses. No se usa más allá
|
|
28
|
+
* del intercambio inicial — ver arriba.
|
|
29
|
+
*
|
|
30
|
+
* ── ⚠️ Las variables de entorno ──
|
|
31
|
+
*
|
|
32
|
+
* Son `GITHUB_APP_*`. `GITHUB_OAUTH_CLIENT_ID` y `GITHUB_OAUTH_CLIENT_SECRET`
|
|
33
|
+
* ya existían y son de OTRA app: la OAuth App que usa `MCP_OAUTH_PRESETS.github`
|
|
34
|
+
* para llegar al servidor MCP de GitHub. Reusar ese par rompería las dos cosas.
|
|
35
|
+
*/
|
|
36
|
+
export declare const GITHUB_APP_OAUTH_CONFIG: {
|
|
37
|
+
readonly authorizationEndpoint: "https://github.com/login/oauth/authorize";
|
|
38
|
+
readonly tokenEndpoint: "https://github.com/login/oauth/access_token";
|
|
39
|
+
readonly apiBase: "https://api.github.com";
|
|
40
|
+
/** La versión de la API REST que fijan todas las llamadas. Fijarla es lo que
|
|
41
|
+
* evita que un cambio de GitHub llegue sin avisar. */
|
|
42
|
+
readonly apiVersion: "2026-03-10";
|
|
43
|
+
/** Nombres de las variables con las credenciales registradas de HW. */
|
|
44
|
+
readonly clientIdEnv: "GITHUB_APP_CLIENT_ID";
|
|
45
|
+
readonly clientSecretEnv: "GITHUB_APP_CLIENT_SECRET";
|
|
46
|
+
readonly appIdEnv: "GITHUB_APP_ID";
|
|
47
|
+
/** La private key va en base64: un PEM en crudo dentro de una variable de
|
|
48
|
+
* entorno se parte por los saltos de línea y sólo se descubre en runtime. */
|
|
49
|
+
readonly privateKeyBase64Env: "GITHUB_APP_PRIVATE_KEY_BASE64";
|
|
50
|
+
/**
|
|
51
|
+
* El secreto con el que GitHub firma cada entrega del webhook.
|
|
52
|
+
*
|
|
53
|
+
* ⚠️ Es el mismo valor que hay escrito en **Webhook → Secret** del registro
|
|
54
|
+
* de la app en github.com. Si los dos no dicen exactamente lo mismo, TODAS
|
|
55
|
+
* las entregas se rechazan con 401 y en GitHub se ven en rojo — el síntoma
|
|
56
|
+
* es un trigger activo que no dispara nunca.
|
|
57
|
+
*
|
|
58
|
+
* Sin la variable no se deja pasar nada: un despliegue al que le falte no
|
|
59
|
+
* debe convertirse en un endpoint abierto por el que cualquiera pueda
|
|
60
|
+
* inyectar eventos. Ver `verificarFirmaGithub`.
|
|
61
|
+
*/
|
|
62
|
+
readonly webhookSecretEnv: "GITHUB_APP_WEBHOOK_SECRET";
|
|
63
|
+
/** El JWT no puede pedir más de 10 minutos. Se usan 9 para dejar margen. */
|
|
64
|
+
readonly jwtLifetimeSeconds: number;
|
|
65
|
+
/** Los docs piden retrasar `iat` para absorber la deriva de reloj. */
|
|
66
|
+
readonly jwtClockSkewSeconds: 60;
|
|
67
|
+
/** Un token de instalación dura 1 h; se re-mina un minuto antes. */
|
|
68
|
+
readonly installationTokenTtlSeconds: number;
|
|
69
|
+
readonly installationTokenSafetySeconds: 60;
|
|
70
|
+
};
|
|
71
|
+
/** El `type` de la credencial que crea este flujo. */
|
|
72
|
+
export declare const GITHUB_APP_CREDENTIAL_TYPE = "github_app";
|
|
73
|
+
/** El `type` de la credencial de token pegado. Mismas operaciones, otra vía. */
|
|
74
|
+
export declare const GITHUB_PAT_CREDENTIAL_TYPE = "github_pat";
|
|
75
|
+
/**
|
|
76
|
+
* Lo que devuelve `POST /login/oauth/access_token`.
|
|
77
|
+
*
|
|
78
|
+
* Todo opcional a propósito: GitHub responde `200` con un cuerpo de error
|
|
79
|
+
* cuando el `code` ya se usó, así que tratar `access_token` como obligatorio
|
|
80
|
+
* convierte un error explicable en un fallo de parseo.
|
|
81
|
+
*/
|
|
82
|
+
export interface GithubTokenResponse {
|
|
83
|
+
access_token?: string;
|
|
84
|
+
token_type?: string;
|
|
85
|
+
scope?: string;
|
|
86
|
+
expires_in?: number;
|
|
87
|
+
refresh_token?: string;
|
|
88
|
+
refresh_token_expires_in?: number;
|
|
89
|
+
error?: string;
|
|
90
|
+
error_description?: string;
|
|
91
|
+
}
|
|
92
|
+
/** Lo que devuelve `POST /app/installations/{id}/access_tokens`. */
|
|
93
|
+
export interface GithubInstallationTokenResponse {
|
|
94
|
+
token?: string;
|
|
95
|
+
/** ISO-8601. Una hora después de emitirlo. */
|
|
96
|
+
expires_at?: string;
|
|
97
|
+
permissions?: Record<string, string>;
|
|
98
|
+
repository_selection?: 'all' | 'selected';
|
|
99
|
+
message?: string;
|
|
100
|
+
}
|
|
101
|
+
/** La instalación, tal como la devuelve `GET /app/installations/{id}`. */
|
|
102
|
+
export interface GithubInstallation {
|
|
103
|
+
id?: number;
|
|
104
|
+
account?: {
|
|
105
|
+
login?: string;
|
|
106
|
+
type?: string;
|
|
107
|
+
avatar_url?: string;
|
|
108
|
+
};
|
|
109
|
+
repository_selection?: 'all' | 'selected';
|
|
110
|
+
app_id?: number;
|
|
111
|
+
target_type?: string;
|
|
112
|
+
}
|
|
113
|
+
/**
|
|
114
|
+
* El blob cifrado de una credencial `github_app`.
|
|
115
|
+
*
|
|
116
|
+
* No lleva token. Es deliberado — ver la cabecera de este fichero.
|
|
117
|
+
*/
|
|
118
|
+
export interface GithubAppStoredPayload {
|
|
119
|
+
/** La instalación. Es lo único que hace falta para minar tokens. */
|
|
120
|
+
installationId: string;
|
|
121
|
+
/** La cuenta donde se instaló, para poder nombrar la credencial y para que
|
|
122
|
+
* el usuario distinga dos instalaciones en la lista. */
|
|
123
|
+
accountLogin?: string;
|
|
124
|
+
accountType?: string;
|
|
125
|
+
/** `all` o `selected`. Cambia el texto que se le enseña al usuario cuando el
|
|
126
|
+
* selector de repos sale corto. */
|
|
127
|
+
repositorySelection?: 'all' | 'selected';
|
|
128
|
+
authMode: 'app';
|
|
129
|
+
}
|
|
130
|
+
/**
|
|
131
|
+
* El blob cifrado de una credencial `github_pat`.
|
|
132
|
+
*
|
|
133
|
+
* Aquí SÍ hay un secreto del usuario, y además caduca: GitHub obliga a poner
|
|
134
|
+
* fecha a los fine-grained. Guardar la fecha permite avisar antes de que una
|
|
135
|
+
* corrida se caiga por una credencial muerta.
|
|
136
|
+
*/
|
|
137
|
+
export interface GithubPatStoredPayload {
|
|
138
|
+
token: string;
|
|
139
|
+
/** ISO-8601, cuando GitHub la ha dicho. */
|
|
140
|
+
expiresAt?: string | null;
|
|
141
|
+
authMode: 'pat';
|
|
142
|
+
}
|
|
143
|
+
export interface PendingGithubOAuthState {
|
|
144
|
+
orgId: string;
|
|
145
|
+
userId: string;
|
|
146
|
+
credentialName: string;
|
|
147
|
+
/** En reconexión, para actualizar la credencial en vez de crear otra. */
|
|
148
|
+
credentialId?: string;
|
|
149
|
+
tags?: string[];
|
|
150
|
+
/** La carpeta elegida en el formulario. Viaja por Redis entre `start` y el
|
|
151
|
+
* callback; sin esto la elección se perdía por el camino. */
|
|
152
|
+
folderId?: string;
|
|
153
|
+
clientId: string;
|
|
154
|
+
clientSecret: string;
|
|
155
|
+
/** Marca absoluta en ms de cuándo caduca este estado pendiente. */
|
|
156
|
+
expiresAt: number;
|
|
157
|
+
}
|
|
158
|
+
/**
|
|
159
|
+
* A dónde se manda al usuario: a **INSTALAR**, no a autorizar.
|
|
160
|
+
*
|
|
161
|
+
* En GitHub son dos gestos distintos y sólo uno sirve para lo que hace este
|
|
162
|
+
* nodo. `/login/oauth/authorize` le da a HostWebhook permiso para actuar en su
|
|
163
|
+
* nombre; **no instala nada**, así que después no hay ninguna instalación de la
|
|
164
|
+
* que sacar un token y la conexión muere con "no lo has instalado en ninguna
|
|
165
|
+
* cuenta". Pasó en producción, con el mensaje correcto y la puerta equivocada.
|
|
166
|
+
*
|
|
167
|
+
* `/apps/{slug}/installations/new` es la buena: lleva por elegir cuenta y
|
|
168
|
+
* elegir repositorios, que es justo la promesa que le hacemos al usuario en el
|
|
169
|
+
* texto de al lado. Y con "Request user authorization (OAuth) during
|
|
170
|
+
* installation" encendido en el registro de la app, al terminar vuelve al
|
|
171
|
+
* Callback URL con el `code` — así que el resto del flujo no cambia.
|
|
172
|
+
*
|
|
173
|
+
* `state` viaja igual y vuelve igual: los docs lo contemplan explícitamente
|
|
174
|
+
* para esta URL, que es lo que permite seguir atando la vuelta con lo que
|
|
175
|
+
* guardamos en Redis.
|
|
176
|
+
*
|
|
177
|
+
* Sigue sin llevar `scope`, por lo de siempre: los permisos de una GitHub App
|
|
178
|
+
* se declaran al registrarla, y lo que el usuario elige son repos.
|
|
179
|
+
*/
|
|
180
|
+
export declare function buildGithubInstallUrl(params: {
|
|
181
|
+
appSlug: string;
|
|
182
|
+
state: string;
|
|
183
|
+
}): string;
|
|
184
|
+
/**
|
|
185
|
+
* El nombre que se ve en la lista de credenciales.
|
|
186
|
+
*
|
|
187
|
+
* Prefiere la cuenta donde se instaló, que es lo que de verdad alcanza el
|
|
188
|
+
* token y lo que necesita alguien que tenga dos instalaciones. El id suelto es
|
|
189
|
+
* el último recurso: es feo, pero es cierto y único, y gana a una fila vacía.
|
|
190
|
+
*/
|
|
191
|
+
export declare function githubDisplayName(inst: GithubInstallation): string;
|
|
192
|
+
/**
|
|
193
|
+
* Decodifica la private key desde la variable en base64.
|
|
194
|
+
*
|
|
195
|
+
* Devuelve `null` en vez de reventar cuando falta o viene mal, para que quien
|
|
196
|
+
* llama pueda dar un mensaje que diga QUÉ variable falta. Un stack trace de
|
|
197
|
+
* `crypto` no le sirve a nadie a las tres de la mañana.
|
|
198
|
+
*/
|
|
199
|
+
export declare function decodeGithubPrivateKey(raw: string | undefined): string | null;
|
|
@@ -0,0 +1,142 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
/**
|
|
3
|
+
* Constantes y formas del OAuth de HostWebhook contra GitHub.
|
|
4
|
+
*
|
|
5
|
+
* ── Qué se guarda, y por qué es distinto a los demás proveedores ──
|
|
6
|
+
*
|
|
7
|
+
* Casi todos los proveedores de esta carpeta guardan un `access_token` del
|
|
8
|
+
* usuario y lo refrescan. Aquí NO. Lo que se guarda es un `installationId`, y
|
|
9
|
+
* **ningún secreto**: cada llamada mina un token de instalación firmando un
|
|
10
|
+
* JWT con la private key de la app, que vive en el entorno.
|
|
11
|
+
*
|
|
12
|
+
* La razón no es estética. Un token de usuario muere cuando esa persona deja
|
|
13
|
+
* la organización, y una automatización que lleva meses corriendo se cae con
|
|
14
|
+
* ella. El token de instalación pertenece a la instalación, no a quien la hizo.
|
|
15
|
+
*
|
|
16
|
+
* Consecuencia práctica: aquí no hay nada que caduque en la base de datos, y
|
|
17
|
+
* el flujo de OAuth existe para dos cosas —comprobar que el usuario autorizó y
|
|
18
|
+
* quedarse con el `installation_id` que vuelve en el callback—, no para
|
|
19
|
+
* quedarse con su token.
|
|
20
|
+
*
|
|
21
|
+
* ── Contra qué docs está escrito (verificado 2026-08-25) ──
|
|
22
|
+
*
|
|
23
|
+
* - JWT: RS256; `iss` es el App ID (los docs admiten también el client id);
|
|
24
|
+
* `iat` **60 segundos en el pasado** por deriva de reloj; `exp` como mucho
|
|
25
|
+
* 10 minutos en el futuro.
|
|
26
|
+
* - Token de instalación: `POST /app/installations/{id}/access_tokens` con
|
|
27
|
+
* el JWT en `Authorization: Bearer`. Dura **1 hora**.
|
|
28
|
+
* - Token de usuario: 8 h (28800 s), refresh de 6 meses. No se usa más allá
|
|
29
|
+
* del intercambio inicial — ver arriba.
|
|
30
|
+
*
|
|
31
|
+
* ── ⚠️ Las variables de entorno ──
|
|
32
|
+
*
|
|
33
|
+
* Son `GITHUB_APP_*`. `GITHUB_OAUTH_CLIENT_ID` y `GITHUB_OAUTH_CLIENT_SECRET`
|
|
34
|
+
* ya existían y son de OTRA app: la OAuth App que usa `MCP_OAUTH_PRESETS.github`
|
|
35
|
+
* para llegar al servidor MCP de GitHub. Reusar ese par rompería las dos cosas.
|
|
36
|
+
*/
|
|
37
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
38
|
+
exports.GITHUB_PAT_CREDENTIAL_TYPE = exports.GITHUB_APP_CREDENTIAL_TYPE = exports.GITHUB_APP_OAUTH_CONFIG = void 0;
|
|
39
|
+
exports.buildGithubInstallUrl = buildGithubInstallUrl;
|
|
40
|
+
exports.githubDisplayName = githubDisplayName;
|
|
41
|
+
exports.decodeGithubPrivateKey = decodeGithubPrivateKey;
|
|
42
|
+
exports.GITHUB_APP_OAUTH_CONFIG = {
|
|
43
|
+
authorizationEndpoint: 'https://github.com/login/oauth/authorize',
|
|
44
|
+
tokenEndpoint: 'https://github.com/login/oauth/access_token',
|
|
45
|
+
apiBase: 'https://api.github.com',
|
|
46
|
+
/** La versión de la API REST que fijan todas las llamadas. Fijarla es lo que
|
|
47
|
+
* evita que un cambio de GitHub llegue sin avisar. */
|
|
48
|
+
apiVersion: '2026-03-10',
|
|
49
|
+
/** Nombres de las variables con las credenciales registradas de HW. */
|
|
50
|
+
clientIdEnv: 'GITHUB_APP_CLIENT_ID',
|
|
51
|
+
clientSecretEnv: 'GITHUB_APP_CLIENT_SECRET',
|
|
52
|
+
appIdEnv: 'GITHUB_APP_ID',
|
|
53
|
+
/** La private key va en base64: un PEM en crudo dentro de una variable de
|
|
54
|
+
* entorno se parte por los saltos de línea y sólo se descubre en runtime. */
|
|
55
|
+
privateKeyBase64Env: 'GITHUB_APP_PRIVATE_KEY_BASE64',
|
|
56
|
+
/**
|
|
57
|
+
* El secreto con el que GitHub firma cada entrega del webhook.
|
|
58
|
+
*
|
|
59
|
+
* ⚠️ Es el mismo valor que hay escrito en **Webhook → Secret** del registro
|
|
60
|
+
* de la app en github.com. Si los dos no dicen exactamente lo mismo, TODAS
|
|
61
|
+
* las entregas se rechazan con 401 y en GitHub se ven en rojo — el síntoma
|
|
62
|
+
* es un trigger activo que no dispara nunca.
|
|
63
|
+
*
|
|
64
|
+
* Sin la variable no se deja pasar nada: un despliegue al que le falte no
|
|
65
|
+
* debe convertirse en un endpoint abierto por el que cualquiera pueda
|
|
66
|
+
* inyectar eventos. Ver `verificarFirmaGithub`.
|
|
67
|
+
*/
|
|
68
|
+
webhookSecretEnv: 'GITHUB_APP_WEBHOOK_SECRET',
|
|
69
|
+
/** El JWT no puede pedir más de 10 minutos. Se usan 9 para dejar margen. */
|
|
70
|
+
jwtLifetimeSeconds: 9 * 60,
|
|
71
|
+
/** Los docs piden retrasar `iat` para absorber la deriva de reloj. */
|
|
72
|
+
jwtClockSkewSeconds: 60,
|
|
73
|
+
/** Un token de instalación dura 1 h; se re-mina un minuto antes. */
|
|
74
|
+
installationTokenTtlSeconds: 60 * 60,
|
|
75
|
+
installationTokenSafetySeconds: 60,
|
|
76
|
+
};
|
|
77
|
+
/** El `type` de la credencial que crea este flujo. */
|
|
78
|
+
exports.GITHUB_APP_CREDENTIAL_TYPE = 'github_app';
|
|
79
|
+
/** El `type` de la credencial de token pegado. Mismas operaciones, otra vía. */
|
|
80
|
+
exports.GITHUB_PAT_CREDENTIAL_TYPE = 'github_pat';
|
|
81
|
+
/**
|
|
82
|
+
* A dónde se manda al usuario: a **INSTALAR**, no a autorizar.
|
|
83
|
+
*
|
|
84
|
+
* En GitHub son dos gestos distintos y sólo uno sirve para lo que hace este
|
|
85
|
+
* nodo. `/login/oauth/authorize` le da a HostWebhook permiso para actuar en su
|
|
86
|
+
* nombre; **no instala nada**, así que después no hay ninguna instalación de la
|
|
87
|
+
* que sacar un token y la conexión muere con "no lo has instalado en ninguna
|
|
88
|
+
* cuenta". Pasó en producción, con el mensaje correcto y la puerta equivocada.
|
|
89
|
+
*
|
|
90
|
+
* `/apps/{slug}/installations/new` es la buena: lleva por elegir cuenta y
|
|
91
|
+
* elegir repositorios, que es justo la promesa que le hacemos al usuario en el
|
|
92
|
+
* texto de al lado. Y con "Request user authorization (OAuth) during
|
|
93
|
+
* installation" encendido en el registro de la app, al terminar vuelve al
|
|
94
|
+
* Callback URL con el `code` — así que el resto del flujo no cambia.
|
|
95
|
+
*
|
|
96
|
+
* `state` viaja igual y vuelve igual: los docs lo contemplan explícitamente
|
|
97
|
+
* para esta URL, que es lo que permite seguir atando la vuelta con lo que
|
|
98
|
+
* guardamos en Redis.
|
|
99
|
+
*
|
|
100
|
+
* Sigue sin llevar `scope`, por lo de siempre: los permisos de una GitHub App
|
|
101
|
+
* se declaran al registrarla, y lo que el usuario elige son repos.
|
|
102
|
+
*/
|
|
103
|
+
function buildGithubInstallUrl(params) {
|
|
104
|
+
const qs = new URLSearchParams({ state: params.state });
|
|
105
|
+
return `https://github.com/apps/${encodeURIComponent(params.appSlug)}/installations/new?${qs.toString()}`;
|
|
106
|
+
}
|
|
107
|
+
/**
|
|
108
|
+
* El nombre que se ve en la lista de credenciales.
|
|
109
|
+
*
|
|
110
|
+
* Prefiere la cuenta donde se instaló, que es lo que de verdad alcanza el
|
|
111
|
+
* token y lo que necesita alguien que tenga dos instalaciones. El id suelto es
|
|
112
|
+
* el último recurso: es feo, pero es cierto y único, y gana a una fila vacía.
|
|
113
|
+
*/
|
|
114
|
+
function githubDisplayName(inst) {
|
|
115
|
+
const login = inst.account?.login?.trim();
|
|
116
|
+
if (login)
|
|
117
|
+
return `GitHub (${login})`;
|
|
118
|
+
return inst.id ? `GitHub (installation ${inst.id})` : 'GitHub';
|
|
119
|
+
}
|
|
120
|
+
/**
|
|
121
|
+
* Decodifica la private key desde la variable en base64.
|
|
122
|
+
*
|
|
123
|
+
* Devuelve `null` en vez de reventar cuando falta o viene mal, para que quien
|
|
124
|
+
* llama pueda dar un mensaje que diga QUÉ variable falta. Un stack trace de
|
|
125
|
+
* `crypto` no le sirve a nadie a las tres de la mañana.
|
|
126
|
+
*/
|
|
127
|
+
function decodeGithubPrivateKey(raw) {
|
|
128
|
+
if (!raw || !raw.trim())
|
|
129
|
+
return null;
|
|
130
|
+
try {
|
|
131
|
+
const pem = Buffer.from(raw.trim(), 'base64').toString('utf8');
|
|
132
|
+
// Comprobar que de verdad salió un PEM y no cualquier cosa que decodifique.
|
|
133
|
+
// Sin esto, una variable con un valor viejo daría un error de firma
|
|
134
|
+
// ilegible varias capas más abajo.
|
|
135
|
+
return pem.includes('-----BEGIN') && pem.includes('PRIVATE KEY')
|
|
136
|
+
? pem
|
|
137
|
+
: null;
|
|
138
|
+
}
|
|
139
|
+
catch {
|
|
140
|
+
return null;
|
|
141
|
+
}
|
|
142
|
+
}
|
|
@@ -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
|
+
}
|