@miguelmorales13/nestkit 0.9.0 → 0.11.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/README.md CHANGED
@@ -580,10 +580,8 @@ descripciones a 800, que son los límites del API — pasarse devuelve un 400.
580
580
 
581
581
  ### `tiktok` — publicar videos, y el modo que evita la auditoría
582
582
 
583
- Tres llamadas, ninguna opcional: preguntar por la cuenta, inicializar la
584
- subida y sondear hasta que TikTok termine de procesar. La primera es un
585
- requisito de sus guías, no una cortesía: la API rechaza publicar si no se
586
- consultó antes qué admite la cuenta.
583
+ Publicar son dos llamadas: inicializar la subida y sondear hasta que TikTok
584
+ termine de procesar.
587
585
 
588
586
  ```ts
589
587
  import { TikTokModule, TikTokService } from '@miguelmorales13/nestkit/tiktok';
@@ -593,11 +591,18 @@ export class AppModule {}
593
591
  ```
594
592
 
595
593
  ```ts
596
- await tiktok.creatorInfo(); // obligatorio antes de publicar
594
+ await tiktok.userInfo(); // comprueba el token sin publicar
597
595
  const id = await tiktok.publish({ data, title }); // sin privacyLevel: a borradores
598
596
  await tiktok.status(id); // hasta PUBLISH_COMPLETE
599
597
  ```
600
598
 
599
+ **Cuidado con `creatorInfo()`.** Parece el paso previo obligatorio —así lo
600
+ sugiere la documentación— pero solo lo es para publicar **en directo**, y
601
+ además exige `video.publish`. Llamarlo con un token de borradores devuelve
602
+ `scope_not_authorized`, o sea que falla justo en la configuración más común.
603
+ Para comprobar que el token vive, `userInfo()`, que se conforma con
604
+ `user.info.basic`.
605
+
601
606
  **La diferencia entre los dos modos importa mucho al principio.** En *Direct
602
607
  Post* el video sale en vivo, pero exige el permiso `video.publish`, que solo se
603
608
  consigue pasando una auditoría de TikTok de varias semanas —y hasta aprobarla
@@ -852,6 +857,28 @@ van a la misma base, una sola clase implementa los dos y se atan con `useExistin
852
857
  `revokeIfActive` **tiene que ser atómico** —un UPDATE condicional, no un leer-y-escribir—: esa
853
858
  fila es lo único que impide que un refresh token robado se use dos veces.
854
859
 
860
+ **El `state` va incluido y no se puede apagar.** Sin él, cualquiera puede entregarle a una persona
861
+ una URL de callback que lleva un `code` de **su propia** cuenta del proveedor: el navegador de la
862
+ víctima completa el flujo y acaba dentro de la sesión del atacante, metiendo sus datos —su peso, su
863
+ dieta, lo que sea— en una cuenta que controla otro. Es el ataque que este parámetro existe para
864
+ cerrar, y es fácil no tenerlo sin notarlo, porque el login funciona igual de bien sin él.
865
+
866
+ `GET /auth/:provider` firma un `state` y lo deja en una cookie al salir; el callback exige que el
867
+ valor que devuelve el proveedor coincida con esa cookie, y lo comprueba **antes** de canjear el
868
+ `code` —si la llamada no salió de un acceso empezado en ese navegador, no hay por qué gastar el
869
+ `code` ni hablar con el proveedor—. La cookie se borra pase lo que pase, así que una pestaña
870
+ olvidada no sirve para repetir el callback más tarde.
871
+
872
+ Va firmado en vez de guardado en una tabla: no necesita esquema ni limpieza y caduca solo. Se
873
+ ajusta con `stateCookieName` y `stateTtlSeconds` (diez minutos por defecto: da para escribir una
874
+ contraseña y pasar un segundo factor, y deja sin valor a una URL filtrada para cuando alguien la
875
+ encuentre).
876
+
877
+ > **Incompatible desde 0.11.0.** `authorizeUrl(provider)` devolvía la URL como string y ahora
878
+ > devuelve `{ url, state, expiresAt }` — el `state` hay que escribirlo en una cookie. Si usás
879
+ > `createOAuthController()` no tenés que hacer nada, ya lo hace. Solo afecta a quien llame al
880
+ > servicio desde un controlador propio.
881
+
855
882
  Personalizable sin tocar la librería: nombre y path de la cookie, TTLs, `sameSite`, a dónde vuelve
856
883
  el navegador tras entrar / fallar / vincular, si un login cierra las demás sesiones
857
884
  (`singleSession`, por defecto sí), qué campos del usuario salen por la API (`mapUser`), el guard de
@@ -980,28 +1007,52 @@ MediaModule.forRoot({
980
1007
  publicUrl: 'https://api.tuapp.com/api', // ¡con el prefijo global!
981
1008
  uploadSecret: process.env.MEDIA_SECRET,
982
1009
  ttlMs: 60 * 60 * 1000, // por defecto una hora
1010
+ // Por defecto: png, jpeg, webp y gif. Ampliá solo con formatos que no
1011
+ // puedan ejecutar nada (ver abajo por qué SVG no está en la lista).
1012
+ allowedContentTypes: ['image/png', 'image/jpeg'],
1013
+ limits: { maxFiles: 50, maxTotalBytes: 256 * 1024 * 1024 }, // los de por defecto
983
1014
  })
984
1015
  ```
985
1016
 
986
1017
  Expone `POST /media` (devuelve `{ url }`) y `GET /media/:id`. La subida va protegida con un secreto
987
1018
  compartido en la cabecera `x-media-secret`, no con un JWT, porque quien llama es un job de CI y no
988
- una persona con sesión; sin él cualquiera podría alojar contenido arbitrario en tu dominio. La
1019
+ una persona con sesión; sin él cualquiera podría alojar contenido arbitrario en tu dominio. Se
1020
+ compara en tiempo constante: un `!==` corta en el primer byte distinto, así que lo que tarda
1021
+ delata cuánto se acertó. La
989
1022
  descarga es pública a propósito: los servicios que consumen estas URLs las piden sin credenciales.
990
1023
 
991
1024
  **Vive en memoria, a propósito.** Entre guardar y publicar pasan segundos, así que un reinicio en el
992
1025
  medio solo significa reintentar, y no queda nada en disco. Si necesitás que los archivos sobrevivan
993
1026
  a un reinicio, esta es la herramienta equivocada: usá `storage`.
994
1027
 
1028
+ Y por vivir en memoria, **tiene techo**: tope de archivos y de bytes, porque esa memoria es el
1029
+ montículo del proceso y las entradas duran una hora. Sin límite, un bucle contra el endpoint de
1030
+ subida —tanto un fallo de un job de publicación como alguien con el secreto— lo hace crecer hasta
1031
+ que el proceso muere, y se lleva por delante la API entera, no solo las subidas. Al llegar al tope
1032
+ **rechaza en vez de desalojar lo más viejo**: desalojar rompería en silencio una publicación en
1033
+ curso, y quien la lanzó no puede saber que su imagen desapareció hasta que el servicio remoto
1034
+ responde un 404 que no se explica.
1035
+
1036
+ **No acepta SVG, y no es un olvido.** Un SVG es un documento que puede llevar script, y servido
1037
+ desde tu dominio ese script corre **en el origen de tu aplicación**: sus cookies, su
1038
+ almacenamiento. Es un XSS almacenado que regala el propio alojador de archivos. Por eso el tipo
1039
+ declarado se coteja contra una lista en vez de tomarse por bueno, y al servir se manda
1040
+ `x-content-type-options: nosniff` para que el navegador tampoco decida por su cuenta.
1041
+
995
1042
  Dos errores que cuestan una tarde:
996
1043
 
997
1044
  - `publicUrl` **tiene que incluir el prefijo global** de la app. Sin él la URL cae en lo que sirva el
998
1045
  frontend, que responde 200 con una página HTML, y el consumidor se descarga eso en vez del archivo.
999
1046
  - Fastify solo parsea JSON de fábrica. Sin registrar un parser para los tipos que vas a aceptar, la
1000
- subida responde 415:
1047
+ subida responde 415. **Listá los tipos, no uses `/^image\//`** —que es lo que decía este
1048
+ ejemplo hasta 0.11.0—: ese patrón deja pasar `image/svg+xml`, y aunque el módulo ahora lo
1049
+ rechace, es mejor que ni siquiera llegue a parsearse:
1001
1050
 
1002
1051
  ```ts
1003
1052
  app.getHttpAdapter().getInstance().addContentTypeParser(
1004
- /^image\//, { parseAs: 'buffer' }, (_req, body, done) => done(null, body),
1053
+ ['image/png', 'image/jpeg', 'image/webp'],
1054
+ { parseAs: 'buffer', bodyLimit: 12 * 1024 * 1024 },
1055
+ (_req, body, done) => done(null, body),
1005
1056
  )
1006
1057
  ```
1007
1058
 
@@ -1072,9 +1123,13 @@ trae los datos.
1072
1123
  | `@miguelmorales13/nestkit/i18n` | `I18nModule` (wrapper de `nestjs-i18n`), `translateOr` |
1073
1124
  | `@miguelmorales13/nestkit/bootstrap` | `applyNestKitDefaults` |
1074
1125
 
1126
+ ## Cambios recientes
1127
+
1128
+ Qué se añadió en cada versión y por qué está hecho así: [CAMBIOS.md](./CAMBIOS.md).
1129
+
1075
1130
  ## Estado del paquete
1076
1131
 
1077
- `0.9.0`. Sin adaptador Mongo real. Sin tests unitarios propios.
1132
+ `0.11.0`. Sin adaptador Mongo real. Sin tests unitarios propios.
1078
1133
 
1079
1134
  `auth/oauth` y `umami` sí tienen un consumidor real: **nutrimx** corre su acceso con Google y
1080
1135
  Facebook sobre este módulo en producción. El resto sigue sin ejercitarse de verdad.
@@ -141,9 +141,11 @@ function resolveOAuthOptions(options) {
141
141
  secure: _nullishCoalesce(_optionalChain([options, 'access', _9 => _9.cookie, 'optionalAccess', _10 => _10.secure]), () => ( process.env.NODE_ENV === "production"))
142
142
  },
143
143
  linkCookieName: _nullishCoalesce(options.linkCookieName, () => ( "link_intent")),
144
+ stateCookieName: _nullishCoalesce(options.stateCookieName, () => ( "oauth_state")),
144
145
  accessTtlSeconds: _nullishCoalesce(options.accessTtlSeconds, () => ( 15 * 60)),
145
146
  refreshTtlSeconds: _nullishCoalesce(options.refreshTtlSeconds, () => ( 30 * 24 * 60 * 60)),
146
147
  linkTtlSeconds: _nullishCoalesce(options.linkTtlSeconds, () => ( 10 * 60)),
148
+ stateTtlSeconds: _nullishCoalesce(options.stateTtlSeconds, () => ( 10 * 60)),
147
149
  accessSecret: options.accessSecret,
148
150
  refreshSecret: options.refreshSecret,
149
151
  singleSession: _nullishCoalesce(options.singleSession, () => ( true)),
@@ -192,8 +194,60 @@ var OAuthAuthService = class {
192
194
  }
193
195
  return found;
194
196
  }
197
+ /**
198
+ * The provider's consent URL, together with the `state` that has to travel
199
+ * back with the callback.
200
+ *
201
+ * `state` is not decoration. Without it, anyone can hand a victim a
202
+ * callback URL carrying an authorization code for *their own* provider
203
+ * account: the victim's browser completes the flow and ends up signed in as
204
+ * the attacker, entering their data into an account someone else controls.
205
+ * The value goes out in the URL and, signed, into a cookie; the callback
206
+ * proceeds only when the two match, which a third party cannot arrange
207
+ * because they cannot write cookies for this domain.
208
+ *
209
+ * Signed rather than stored in a table: it needs no schema and no cleanup,
210
+ * and it expires on its own.
211
+ */
195
212
  authorizeUrl(name) {
196
- return buildAuthorizeUrl(this.provider(name));
213
+ const provider = this.provider(name);
214
+ const ttl = this.options.stateTtlSeconds;
215
+ const state = _jsonwebtoken2.default.sign(
216
+ // `nonce` makes two authorize requests within the same second produce
217
+ // different tokens; `typ` keeps this from being swapped with the link
218
+ // token, which is signed with the same secret.
219
+ { typ: "state", provider: provider.name, nonce: _crypto.randomUUID.call(void 0, ) },
220
+ requireSecret(this.options.accessSecret, "JWT_ACCESS_SECRET"),
221
+ { expiresIn: ttl }
222
+ );
223
+ return {
224
+ url: buildAuthorizeUrl(provider, state),
225
+ state,
226
+ expiresAt: new Date(Date.now() + ttl * 1e3)
227
+ };
228
+ }
229
+ /**
230
+ * Whether the `state` the provider sent back is the one issued for this
231
+ * provider, to this browser, and still valid.
232
+ *
233
+ * Both halves are checked. Matching the cookie proves the callback belongs
234
+ * to the authorize request this browser started; verifying the signature
235
+ * proves the pair was not simply invented by whoever crafted the URL.
236
+ */
237
+ verifyState(received, fromCookie, provider) {
238
+ if (!received || !fromCookie) return false;
239
+ const a = Buffer.from(received);
240
+ const b = Buffer.from(fromCookie);
241
+ if (a.length !== b.length || !_crypto.timingSafeEqual.call(void 0, a, b)) return false;
242
+ try {
243
+ const payload = _jsonwebtoken2.default.verify(
244
+ received,
245
+ requireSecret(this.options.accessSecret, "JWT_ACCESS_SECRET")
246
+ );
247
+ return payload.typ === "state" && payload.provider === provider;
248
+ } catch (e2) {
249
+ return false;
250
+ }
197
251
  }
198
252
  /** Exchanges the callback's `code` for a normalised profile. */
199
253
  async profileFromCode(name, code) {
@@ -303,7 +357,7 @@ var OAuthAuthService = class {
303
357
  requireSecret(this.options.accessSecret, "JWT_ACCESS_SECRET")
304
358
  );
305
359
  return payload.typ === "link" && payload.sub ? payload.sub : null;
306
- } catch (e2) {
360
+ } catch (e3) {
307
361
  return null;
308
362
  }
309
363
  }
@@ -388,7 +442,7 @@ var OAuthAuthService = class {
388
442
  token,
389
443
  requireSecret(this.options.refreshSecret, "JWT_REFRESH_SECRET")
390
444
  );
391
- } catch (e3) {
445
+ } catch (e4) {
392
446
  throw new (0, _chunkFDNGAYTZcjs.UnauthorizedAppException)("Invalid refresh token.");
393
447
  }
394
448
  }
@@ -491,12 +545,24 @@ function createOAuthController(options = {}) {
491
545
  return { ok: true };
492
546
  }
493
547
  start(provider, reply) {
494
- redirect(reply, this.auth.authorizeUrl(provider));
548
+ const { url, state, expiresAt } = this.auth.authorizeUrl(provider);
549
+ setCookie(reply, this.auth.config.stateCookieName, state, {
550
+ ...this.cookieBase(),
551
+ expires: expiresAt
552
+ });
553
+ redirect(reply, url);
495
554
  }
496
- async callback(provider, code, request, reply) {
555
+ async callback(provider, code, state, request, reply) {
497
556
  if (!code) throw new (0, _chunkFDNGAYTZcjs.UnauthorizedAppException)(this.auth.config.genericErrorMessage());
557
+ const stateCookie = _optionalChain([request, 'access', _19 => _19.cookies, 'optionalAccess', _20 => _20[this.auth.config.stateCookieName]]);
558
+ clearCookie(reply, this.auth.config.stateCookieName, {
559
+ path: this.auth.config.cookie.path
560
+ });
561
+ if (!this.auth.verifyState(state, stateCookie, provider)) {
562
+ throw new (0, _chunkFDNGAYTZcjs.UnauthorizedAppException)(this.auth.config.genericErrorMessage());
563
+ }
498
564
  const profile = await this.auth.profileFromCode(provider, code);
499
- const linkUserId = this.auth.readLinkToken(_optionalChain([request, 'access', _19 => _19.cookies, 'optionalAccess', _20 => _20[this.auth.config.linkCookieName]]));
565
+ const linkUserId = this.auth.readLinkToken(_optionalChain([request, 'access', _21 => _21.cookies, 'optionalAccess', _22 => _22[this.auth.config.linkCookieName]]));
500
566
  if (linkUserId) {
501
567
  clearCookie(reply, this.auth.config.linkCookieName, { path: this.auth.config.cookie.path });
502
568
  try {
@@ -562,8 +628,9 @@ function createOAuthController(options = {}) {
562
628
  _common.UseFilters.call(void 0, OAuthCallbackFilter),
563
629
  _chunk2REOCMUDcjs.__decorateParam.call(void 0, 0, _common.Param.call(void 0, "provider")),
564
630
  _chunk2REOCMUDcjs.__decorateParam.call(void 0, 1, _common.Query.call(void 0, "code")),
565
- _chunk2REOCMUDcjs.__decorateParam.call(void 0, 2, _common.Req.call(void 0, )),
566
- _chunk2REOCMUDcjs.__decorateParam.call(void 0, 3, _common.Res.call(void 0, ))
631
+ _chunk2REOCMUDcjs.__decorateParam.call(void 0, 2, _common.Query.call(void 0, "state")),
632
+ _chunk2REOCMUDcjs.__decorateParam.call(void 0, 3, _common.Req.call(void 0, )),
633
+ _chunk2REOCMUDcjs.__decorateParam.call(void 0, 4, _common.Res.call(void 0, ))
567
634
  ], OAuthControllerHost.prototype, "callback", 1);
568
635
  OAuthControllerHost = _chunk2REOCMUDcjs.__decorateClass.call(void 0, [
569
636
  _common.Controller.call(void 0, _nullishCoalesce(options.path, () => ( "auth")))
@@ -141,9 +141,11 @@ function resolveOAuthOptions(options) {
141
141
  secure: options.cookie?.secure ?? process.env.NODE_ENV === "production"
142
142
  },
143
143
  linkCookieName: options.linkCookieName ?? "link_intent",
144
+ stateCookieName: options.stateCookieName ?? "oauth_state",
144
145
  accessTtlSeconds: options.accessTtlSeconds ?? 15 * 60,
145
146
  refreshTtlSeconds: options.refreshTtlSeconds ?? 30 * 24 * 60 * 60,
146
147
  linkTtlSeconds: options.linkTtlSeconds ?? 10 * 60,
148
+ stateTtlSeconds: options.stateTtlSeconds ?? 10 * 60,
147
149
  accessSecret: options.accessSecret,
148
150
  refreshSecret: options.refreshSecret,
149
151
  singleSession: options.singleSession ?? true,
@@ -157,7 +159,7 @@ function resolveOAuthOptions(options) {
157
159
 
158
160
  // src/auth/oauth/oauth-auth.service.ts
159
161
  import { Inject, Injectable, ServiceUnavailableException } from "@nestjs/common";
160
- import { createHash, randomUUID } from "crypto";
162
+ import { createHash, randomUUID, timingSafeEqual } from "crypto";
161
163
  import jwt from "jsonwebtoken";
162
164
  function hashToken(token) {
163
165
  return createHash("sha256").update(token).digest("hex");
@@ -192,8 +194,60 @@ var OAuthAuthService = class {
192
194
  }
193
195
  return found;
194
196
  }
197
+ /**
198
+ * The provider's consent URL, together with the `state` that has to travel
199
+ * back with the callback.
200
+ *
201
+ * `state` is not decoration. Without it, anyone can hand a victim a
202
+ * callback URL carrying an authorization code for *their own* provider
203
+ * account: the victim's browser completes the flow and ends up signed in as
204
+ * the attacker, entering their data into an account someone else controls.
205
+ * The value goes out in the URL and, signed, into a cookie; the callback
206
+ * proceeds only when the two match, which a third party cannot arrange
207
+ * because they cannot write cookies for this domain.
208
+ *
209
+ * Signed rather than stored in a table: it needs no schema and no cleanup,
210
+ * and it expires on its own.
211
+ */
195
212
  authorizeUrl(name) {
196
- return buildAuthorizeUrl(this.provider(name));
213
+ const provider = this.provider(name);
214
+ const ttl = this.options.stateTtlSeconds;
215
+ const state = jwt.sign(
216
+ // `nonce` makes two authorize requests within the same second produce
217
+ // different tokens; `typ` keeps this from being swapped with the link
218
+ // token, which is signed with the same secret.
219
+ { typ: "state", provider: provider.name, nonce: randomUUID() },
220
+ requireSecret(this.options.accessSecret, "JWT_ACCESS_SECRET"),
221
+ { expiresIn: ttl }
222
+ );
223
+ return {
224
+ url: buildAuthorizeUrl(provider, state),
225
+ state,
226
+ expiresAt: new Date(Date.now() + ttl * 1e3)
227
+ };
228
+ }
229
+ /**
230
+ * Whether the `state` the provider sent back is the one issued for this
231
+ * provider, to this browser, and still valid.
232
+ *
233
+ * Both halves are checked. Matching the cookie proves the callback belongs
234
+ * to the authorize request this browser started; verifying the signature
235
+ * proves the pair was not simply invented by whoever crafted the URL.
236
+ */
237
+ verifyState(received, fromCookie, provider) {
238
+ if (!received || !fromCookie) return false;
239
+ const a = Buffer.from(received);
240
+ const b = Buffer.from(fromCookie);
241
+ if (a.length !== b.length || !timingSafeEqual(a, b)) return false;
242
+ try {
243
+ const payload = jwt.verify(
244
+ received,
245
+ requireSecret(this.options.accessSecret, "JWT_ACCESS_SECRET")
246
+ );
247
+ return payload.typ === "state" && payload.provider === provider;
248
+ } catch {
249
+ return false;
250
+ }
197
251
  }
198
252
  /** Exchanges the callback's `code` for a normalised profile. */
199
253
  async profileFromCode(name, code) {
@@ -491,10 +545,22 @@ function createOAuthController(options = {}) {
491
545
  return { ok: true };
492
546
  }
493
547
  start(provider, reply) {
494
- redirect(reply, this.auth.authorizeUrl(provider));
548
+ const { url, state, expiresAt } = this.auth.authorizeUrl(provider);
549
+ setCookie(reply, this.auth.config.stateCookieName, state, {
550
+ ...this.cookieBase(),
551
+ expires: expiresAt
552
+ });
553
+ redirect(reply, url);
495
554
  }
496
- async callback(provider, code, request, reply) {
555
+ async callback(provider, code, state, request, reply) {
497
556
  if (!code) throw new UnauthorizedAppException(this.auth.config.genericErrorMessage());
557
+ const stateCookie = request.cookies?.[this.auth.config.stateCookieName];
558
+ clearCookie(reply, this.auth.config.stateCookieName, {
559
+ path: this.auth.config.cookie.path
560
+ });
561
+ if (!this.auth.verifyState(state, stateCookie, provider)) {
562
+ throw new UnauthorizedAppException(this.auth.config.genericErrorMessage());
563
+ }
498
564
  const profile = await this.auth.profileFromCode(provider, code);
499
565
  const linkUserId = this.auth.readLinkToken(request.cookies?.[this.auth.config.linkCookieName]);
500
566
  if (linkUserId) {
@@ -562,8 +628,9 @@ function createOAuthController(options = {}) {
562
628
  UseFilters(OAuthCallbackFilter),
563
629
  __decorateParam(0, Param("provider")),
564
630
  __decorateParam(1, Query("code")),
565
- __decorateParam(2, Req()),
566
- __decorateParam(3, Res())
631
+ __decorateParam(2, Query("state")),
632
+ __decorateParam(3, Req()),
633
+ __decorateParam(4, Res())
567
634
  ], OAuthControllerHost.prototype, "callback", 1);
568
635
  OAuthControllerHost = __decorateClass([
569
636
  Controller(options.path ?? "auth")
@@ -29,7 +29,35 @@ export declare class OAuthAuthService<U extends OAuthUser = OAuthUser> {
29
29
  * differently, and distinguishing them only tells a prober what exists.
30
30
  */
31
31
  provider(name: string): OAuthProviderConfig;
32
- authorizeUrl(name: string): string;
32
+ /**
33
+ * The provider's consent URL, together with the `state` that has to travel
34
+ * back with the callback.
35
+ *
36
+ * `state` is not decoration. Without it, anyone can hand a victim a
37
+ * callback URL carrying an authorization code for *their own* provider
38
+ * account: the victim's browser completes the flow and ends up signed in as
39
+ * the attacker, entering their data into an account someone else controls.
40
+ * The value goes out in the URL and, signed, into a cookie; the callback
41
+ * proceeds only when the two match, which a third party cannot arrange
42
+ * because they cannot write cookies for this domain.
43
+ *
44
+ * Signed rather than stored in a table: it needs no schema and no cleanup,
45
+ * and it expires on its own.
46
+ */
47
+ authorizeUrl(name: string): {
48
+ url: string;
49
+ state: string;
50
+ expiresAt: Date;
51
+ };
52
+ /**
53
+ * Whether the `state` the provider sent back is the one issued for this
54
+ * provider, to this browser, and still valid.
55
+ *
56
+ * Both halves are checked. Matching the cookie proves the callback belongs
57
+ * to the authorize request this browser started; verifying the signature
58
+ * proves the pair was not simply invented by whoever crafted the URL.
59
+ */
60
+ verifyState(received: string | undefined, fromCookie: string | undefined, provider: string): boolean;
33
61
  /** Exchanges the callback's `code` for a normalised profile. */
34
62
  profileFromCode(name: string, code: string): Promise<OAuthProfile>;
35
63
  /**
@@ -41,10 +41,21 @@ export interface OAuthAuthOptions<U extends OAuthUser = OAuthUser> {
41
41
  * already signed-in account. Default `link_intent`.
42
42
  */
43
43
  linkCookieName?: string;
44
+ /**
45
+ * Cookie holding the `state` that ties an authorize request to its
46
+ * callback. Defaults to `oauth_state`.
47
+ */
48
+ stateCookieName?: string;
44
49
  /** Defaults: 15 min access, 30 days refresh, 10 min link permission. */
45
50
  accessTtlSeconds?: number;
46
51
  refreshTtlSeconds?: number;
47
52
  linkTtlSeconds?: number;
53
+ /**
54
+ * How long a `state` stays valid. Defaults to ten minutes — long enough to
55
+ * type a password and pass a second factor, short enough that a leaked
56
+ * authorize URL is worthless by the time anyone finds it.
57
+ */
58
+ stateTtlSeconds?: number;
48
59
  /** Read from `JWT_ACCESS_SECRET` / `JWT_REFRESH_SECRET` when omitted. */
49
60
  accessSecret?: string;
50
61
  refreshSecret?: string;
package/dist/index.cjs CHANGED
@@ -25,6 +25,10 @@ var _chunkTJHRABMLcjs = require('./chunk-TJHRABML.cjs');
25
25
 
26
26
 
27
27
  var _chunk3CLYZC3Tcjs = require('./chunk-3CLYZC3T.cjs');
28
+
29
+
30
+
31
+ var _chunkM3EL5O6Tcjs = require('./chunk-M3EL5O6T.cjs');
28
32
  require('./chunk-7SOM7EZP.cjs');
29
33
 
30
34
 
@@ -45,10 +49,6 @@ var _chunkR7BVS6CIcjs = require('./chunk-R7BVS6CI.cjs');
45
49
 
46
50
 
47
51
  var _chunkQS2W5XCQcjs = require('./chunk-QS2W5XCQ.cjs');
48
-
49
-
50
-
51
- var _chunkM3EL5O6Tcjs = require('./chunk-M3EL5O6T.cjs');
52
52
  require('./chunk-2REOCMUD.cjs');
53
53
 
54
54
 
package/dist/index.js CHANGED
@@ -25,6 +25,10 @@ import {
25
25
  BaseCrudService,
26
26
  createCrudController
27
27
  } from "./chunk-JOVBJDJ2.js";
28
+ import {
29
+ I18nModule,
30
+ translateOr
31
+ } from "./chunk-IYUUYCP5.js";
28
32
  import "./chunk-DQYAIQQ5.js";
29
33
  import {
30
34
  ConflictAppException,
@@ -45,10 +49,6 @@ import {
45
49
  import {
46
50
  BaseResponseDto
47
51
  } from "./chunk-XX2HPTRU.js";
48
- import {
49
- I18nModule,
50
- translateOr
51
- } from "./chunk-IYUUYCP5.js";
52
52
  import "./chunk-4MGIQFAJ.js";
53
53
  export {
54
54
  AppException,
@@ -20,28 +20,60 @@ var _common = require('@nestjs/common');
20
20
 
21
21
 
22
22
 
23
+ var _crypto = require('crypto');
24
+
23
25
  // src/media/media.service.ts
24
26
 
25
27
 
26
28
  // src/media/media.options.ts
27
29
  var MEDIA_OPTIONS = /* @__PURE__ */ Symbol("MEDIA_OPTIONS");
28
30
  var MEDIA_TTL = /* @__PURE__ */ Symbol("MEDIA_TTL");
31
+ var MEDIA_LIMITS = /* @__PURE__ */ Symbol("MEDIA_LIMITS");
29
32
 
30
33
  // src/media/media.service.ts
31
- var _crypto = require('crypto');
34
+
32
35
  var MediaService = class {
33
- constructor(ttlMs) {
36
+ constructor(ttlMs, limits) {
34
37
  this.ttlMs = ttlMs;
38
+ this.limits = limits;
35
39
  this.logger = new (0, _common.Logger)(MediaService.name);
36
40
  this.store = /* @__PURE__ */ new Map();
37
41
  }
42
+ /**
43
+ * Stores a file, or throws if the store is already full.
44
+ *
45
+ * The ceiling matters more than it looks. This map is the process's own
46
+ * heap, entries live for an hour, and nothing here bounded how many there
47
+ * could be: a loop against the upload endpoint — a bug in a publishing job
48
+ * as easily as someone with the secret — grows it until the process dies,
49
+ * and it takes the whole API down with it, not just uploads.
50
+ *
51
+ * Refusing is the right answer rather than evicting the oldest: an eviction
52
+ * would silently break a publication already in flight, and the caller
53
+ * cannot tell that its image vanished until the remote service reports a
54
+ * 404 it cannot explain.
55
+ */
38
56
  put(data, contentType) {
39
57
  this.sweep();
58
+ if (this.store.size >= this.limits.maxFiles) {
59
+ throw new (0, _common.PayloadTooLargeException)(
60
+ `The media store is full (${this.limits.maxFiles} files). Try again shortly.`
61
+ );
62
+ }
63
+ const total = this.bytesHeld();
64
+ if (total + data.length > this.limits.maxTotalBytes) {
65
+ throw new (0, _common.PayloadTooLargeException)("The media store is out of room. Try again shortly.");
66
+ }
40
67
  const id = _crypto.randomUUID.call(void 0, ).replace(/-/g, "");
41
68
  this.store.set(id, { data, contentType, expiresAt: Date.now() + this.ttlMs });
42
69
  this.logger.log(`Stored ${id} (${(data.length / 1024).toFixed(0)} KB)`);
43
70
  return id;
44
71
  }
72
+ bytesHeld() {
73
+ let total = 0;
74
+ for (const entry of this.store.values()) total += entry.data.length;
75
+ return total;
76
+ }
45
77
  get(id) {
46
78
  const found = this.store.get(id);
47
79
  if (!found) return void 0;
@@ -61,10 +93,18 @@ var MediaService = class {
61
93
  };
62
94
  MediaService = exports.MediaService = _chunk2REOCMUDcjs.__decorateClass.call(void 0, [
63
95
  _common.Injectable.call(void 0, ),
64
- _chunk2REOCMUDcjs.__decorateParam.call(void 0, 0, _common.Inject.call(void 0, MEDIA_TTL))
96
+ _chunk2REOCMUDcjs.__decorateParam.call(void 0, 0, _common.Inject.call(void 0, MEDIA_TTL)),
97
+ _chunk2REOCMUDcjs.__decorateParam.call(void 0, 1, _common.Inject.call(void 0, MEDIA_LIMITS))
65
98
  ], MediaService);
66
99
 
67
100
  // src/media/media.controller.ts
101
+ function secretMatches(received, expected) {
102
+ if (!received) return false;
103
+ const a = Buffer.from(received);
104
+ const b = Buffer.from(expected);
105
+ return a.length === b.length && _crypto.timingSafeEqual.call(void 0, a, b);
106
+ }
107
+ var DEFAULT_ALLOWED = ["image/png", "image/jpeg", "image/webp", "image/gif"];
68
108
  var MediaController = class {
69
109
  // Both dependencies are injected by explicit token, like the rest of this
70
110
  // package. The bundler does not emit decorator metadata, so injecting by type
@@ -78,18 +118,25 @@ var MediaController = class {
78
118
  if (!this.options.uploadSecret) {
79
119
  throw new (0, _common.UnauthorizedException)("Media uploads are not configured.");
80
120
  }
81
- if (secret !== this.options.uploadSecret) {
121
+ if (!secretMatches(secret, this.options.uploadSecret)) {
82
122
  throw new (0, _common.UnauthorizedException)("Invalid secret.");
83
123
  }
84
124
  const data = request.body;
85
125
  if (!_optionalChain([data, 'optionalAccess', _ => _.length])) throw new (0, _common.UnauthorizedException)("Empty body.");
86
- const id = this.media.put(Buffer.from(data), _nullishCoalesce(request.headers["content-type"], () => ( "image/png")));
126
+ const allowed = _nullishCoalesce(this.options.allowedContentTypes, () => ( DEFAULT_ALLOWED));
127
+ const declared = _optionalChain([request, 'access', _2 => _2.headers, 'access', _3 => _3["content-type"], 'optionalAccess', _4 => _4.split, 'call', _5 => _5(";"), 'access', _6 => _6[0], 'optionalAccess', _7 => _7.trim, 'call', _8 => _8(), 'access', _9 => _9.toLowerCase, 'call', _10 => _10()]);
128
+ if (!declared || !allowed.includes(declared)) {
129
+ throw new (0, _common.BadRequestException)(
130
+ `Unsupported content type. Allowed: ${allowed.join(", ")}.`
131
+ );
132
+ }
133
+ const id = this.media.put(Buffer.from(data), declared);
87
134
  return { url: `${this.options.publicUrl}/media/${id}` };
88
135
  }
89
136
  serve(id, reply) {
90
137
  const found = this.media.get(id);
91
138
  if (!found) throw new (0, _common.NotFoundException)("That file is no longer available.");
92
- reply.header("content-type", found.contentType).header("cache-control", "no-store").send(found.data);
139
+ reply.header("content-type", found.contentType).header("cache-control", "no-store").header("x-content-type-options", "nosniff").send(found.data);
93
140
  }
94
141
  };
95
142
  _chunk2REOCMUDcjs.__decorateClass.call(void 0, [
@@ -110,6 +157,7 @@ MediaController = _chunk2REOCMUDcjs.__decorateClass.call(void 0, [
110
157
 
111
158
  // src/media/media.module.ts
112
159
  var ONE_HOUR = 60 * 60 * 1e3;
160
+ var DEFAULT_LIMITS = { maxFiles: 50, maxTotalBytes: 256 * 1024 * 1024 };
113
161
  var MediaModule = class {
114
162
  static forRoot(options) {
115
163
  return {
@@ -118,6 +166,7 @@ var MediaModule = class {
118
166
  providers: [
119
167
  { provide: MEDIA_OPTIONS, useValue: options },
120
168
  { provide: MEDIA_TTL, useValue: _nullishCoalesce(options.ttlMs, () => ( ONE_HOUR)) },
169
+ { provide: MEDIA_LIMITS, useValue: { ...DEFAULT_LIMITS, ...options.limits } },
121
170
  MediaService
122
171
  ],
123
172
  exports: [MediaService]
@@ -132,4 +181,5 @@ MediaModule = exports.MediaModule = _chunk2REOCMUDcjs.__decorateClass.call(void
132
181
 
133
182
 
134
183
 
135
- exports.MEDIA_OPTIONS = MEDIA_OPTIONS; exports.MEDIA_TTL = MEDIA_TTL; exports.MediaModule = MediaModule; exports.MediaService = MediaService;
184
+
185
+ exports.MEDIA_LIMITS = MEDIA_LIMITS; exports.MEDIA_OPTIONS = MEDIA_OPTIONS; exports.MEDIA_TTL = MEDIA_TTL; exports.MediaModule = MediaModule; exports.MediaService = MediaService;
@@ -1,5 +1,5 @@
1
1
  export { MediaModule } from './media.module.js';
2
2
  export { MediaService } from './media.service.js';
3
- export { MEDIA_OPTIONS, MEDIA_TTL } from './media.options.js';
4
- export type { MediaOptions } from './media.options.js';
3
+ export { MEDIA_OPTIONS, MEDIA_TTL, MEDIA_LIMITS } from './media.options.js';
4
+ export type { MediaOptions, MediaLimits } from './media.options.js';
5
5
  export type { StoredMedia } from './media.service.js';
@@ -8,6 +8,7 @@ import { Module } from "@nestjs/common";
8
8
 
9
9
  // src/media/media.controller.ts
10
10
  import {
11
+ BadRequestException,
11
12
  Controller,
12
13
  Get,
13
14
  Headers,
@@ -19,29 +20,60 @@ import {
19
20
  Res,
20
21
  UnauthorizedException
21
22
  } from "@nestjs/common";
23
+ import { timingSafeEqual } from "crypto";
22
24
 
23
25
  // src/media/media.service.ts
24
- import { Inject, Injectable, Logger } from "@nestjs/common";
26
+ import { Inject, Injectable, Logger, PayloadTooLargeException } from "@nestjs/common";
25
27
 
26
28
  // src/media/media.options.ts
27
29
  var MEDIA_OPTIONS = /* @__PURE__ */ Symbol("MEDIA_OPTIONS");
28
30
  var MEDIA_TTL = /* @__PURE__ */ Symbol("MEDIA_TTL");
31
+ var MEDIA_LIMITS = /* @__PURE__ */ Symbol("MEDIA_LIMITS");
29
32
 
30
33
  // src/media/media.service.ts
31
34
  import { randomUUID } from "crypto";
32
35
  var MediaService = class {
33
- constructor(ttlMs) {
36
+ constructor(ttlMs, limits) {
34
37
  this.ttlMs = ttlMs;
38
+ this.limits = limits;
35
39
  this.logger = new Logger(MediaService.name);
36
40
  this.store = /* @__PURE__ */ new Map();
37
41
  }
42
+ /**
43
+ * Stores a file, or throws if the store is already full.
44
+ *
45
+ * The ceiling matters more than it looks. This map is the process's own
46
+ * heap, entries live for an hour, and nothing here bounded how many there
47
+ * could be: a loop against the upload endpoint — a bug in a publishing job
48
+ * as easily as someone with the secret — grows it until the process dies,
49
+ * and it takes the whole API down with it, not just uploads.
50
+ *
51
+ * Refusing is the right answer rather than evicting the oldest: an eviction
52
+ * would silently break a publication already in flight, and the caller
53
+ * cannot tell that its image vanished until the remote service reports a
54
+ * 404 it cannot explain.
55
+ */
38
56
  put(data, contentType) {
39
57
  this.sweep();
58
+ if (this.store.size >= this.limits.maxFiles) {
59
+ throw new PayloadTooLargeException(
60
+ `The media store is full (${this.limits.maxFiles} files). Try again shortly.`
61
+ );
62
+ }
63
+ const total = this.bytesHeld();
64
+ if (total + data.length > this.limits.maxTotalBytes) {
65
+ throw new PayloadTooLargeException("The media store is out of room. Try again shortly.");
66
+ }
40
67
  const id = randomUUID().replace(/-/g, "");
41
68
  this.store.set(id, { data, contentType, expiresAt: Date.now() + this.ttlMs });
42
69
  this.logger.log(`Stored ${id} (${(data.length / 1024).toFixed(0)} KB)`);
43
70
  return id;
44
71
  }
72
+ bytesHeld() {
73
+ let total = 0;
74
+ for (const entry of this.store.values()) total += entry.data.length;
75
+ return total;
76
+ }
45
77
  get(id) {
46
78
  const found = this.store.get(id);
47
79
  if (!found) return void 0;
@@ -61,10 +93,18 @@ var MediaService = class {
61
93
  };
62
94
  MediaService = __decorateClass([
63
95
  Injectable(),
64
- __decorateParam(0, Inject(MEDIA_TTL))
96
+ __decorateParam(0, Inject(MEDIA_TTL)),
97
+ __decorateParam(1, Inject(MEDIA_LIMITS))
65
98
  ], MediaService);
66
99
 
67
100
  // src/media/media.controller.ts
101
+ function secretMatches(received, expected) {
102
+ if (!received) return false;
103
+ const a = Buffer.from(received);
104
+ const b = Buffer.from(expected);
105
+ return a.length === b.length && timingSafeEqual(a, b);
106
+ }
107
+ var DEFAULT_ALLOWED = ["image/png", "image/jpeg", "image/webp", "image/gif"];
68
108
  var MediaController = class {
69
109
  // Both dependencies are injected by explicit token, like the rest of this
70
110
  // package. The bundler does not emit decorator metadata, so injecting by type
@@ -78,18 +118,25 @@ var MediaController = class {
78
118
  if (!this.options.uploadSecret) {
79
119
  throw new UnauthorizedException("Media uploads are not configured.");
80
120
  }
81
- if (secret !== this.options.uploadSecret) {
121
+ if (!secretMatches(secret, this.options.uploadSecret)) {
82
122
  throw new UnauthorizedException("Invalid secret.");
83
123
  }
84
124
  const data = request.body;
85
125
  if (!data?.length) throw new UnauthorizedException("Empty body.");
86
- const id = this.media.put(Buffer.from(data), request.headers["content-type"] ?? "image/png");
126
+ const allowed = this.options.allowedContentTypes ?? DEFAULT_ALLOWED;
127
+ const declared = request.headers["content-type"]?.split(";")[0]?.trim().toLowerCase();
128
+ if (!declared || !allowed.includes(declared)) {
129
+ throw new BadRequestException(
130
+ `Unsupported content type. Allowed: ${allowed.join(", ")}.`
131
+ );
132
+ }
133
+ const id = this.media.put(Buffer.from(data), declared);
87
134
  return { url: `${this.options.publicUrl}/media/${id}` };
88
135
  }
89
136
  serve(id, reply) {
90
137
  const found = this.media.get(id);
91
138
  if (!found) throw new NotFoundException("That file is no longer available.");
92
- reply.header("content-type", found.contentType).header("cache-control", "no-store").send(found.data);
139
+ reply.header("content-type", found.contentType).header("cache-control", "no-store").header("x-content-type-options", "nosniff").send(found.data);
93
140
  }
94
141
  };
95
142
  __decorateClass([
@@ -110,6 +157,7 @@ MediaController = __decorateClass([
110
157
 
111
158
  // src/media/media.module.ts
112
159
  var ONE_HOUR = 60 * 60 * 1e3;
160
+ var DEFAULT_LIMITS = { maxFiles: 50, maxTotalBytes: 256 * 1024 * 1024 };
113
161
  var MediaModule = class {
114
162
  static forRoot(options) {
115
163
  return {
@@ -118,6 +166,7 @@ var MediaModule = class {
118
166
  providers: [
119
167
  { provide: MEDIA_OPTIONS, useValue: options },
120
168
  { provide: MEDIA_TTL, useValue: options.ttlMs ?? ONE_HOUR },
169
+ { provide: MEDIA_LIMITS, useValue: { ...DEFAULT_LIMITS, ...options.limits } },
121
170
  MediaService
122
171
  ],
123
172
  exports: [MediaService]
@@ -128,6 +177,7 @@ MediaModule = __decorateClass([
128
177
  Module({})
129
178
  ], MediaModule);
130
179
  export {
180
+ MEDIA_LIMITS,
131
181
  MEDIA_OPTIONS,
132
182
  MEDIA_TTL,
133
183
  MediaModule,
@@ -1,5 +1,11 @@
1
1
  export declare const MEDIA_OPTIONS: unique symbol;
2
2
  export declare const MEDIA_TTL: unique symbol;
3
+ export declare const MEDIA_LIMITS: unique symbol;
4
+ /** What the store refuses to exceed. See `MediaService` for why it has to. */
5
+ export interface MediaLimits {
6
+ maxFiles: number;
7
+ maxTotalBytes: number;
8
+ }
3
9
  export interface MediaOptions {
4
10
  /**
5
11
  * Base URL the generated links point to, **including the global prefix** if
@@ -9,6 +15,20 @@ export interface MediaOptions {
9
15
  publicUrl: string;
10
16
  /** Shared secret required to upload. Without it, uploads are refused. */
11
17
  uploadSecret?: string;
18
+ /**
19
+ * Content types accepted on upload and echoed back when serving.
20
+ *
21
+ * Defaults to PNG, JPEG, WebP and GIF — deliberately **not** SVG. An SVG is
22
+ * a document that can carry script, and serving one from your own domain
23
+ * runs that script in your application's origin: same cookies, same
24
+ * storage. Widen this only for formats that cannot execute.
25
+ */
26
+ allowedContentTypes?: string[];
27
+ /**
28
+ * Ceiling on what the store may hold at once. Defaults to 50 files and
29
+ * 256 MB. Uploads beyond it are refused rather than queued.
30
+ */
31
+ limits?: Partial<MediaLimits>;
12
32
  /** How long a file stays available. Defaults to one hour. */
13
33
  ttlMs?: number;
14
34
  }
@@ -1,3 +1,4 @@
1
+ import { type MediaLimits } from './media.options.js';
1
2
  export interface StoredMedia {
2
3
  data: Buffer;
3
4
  contentType: string;
@@ -18,10 +19,26 @@ export interface StoredMedia {
18
19
  */
19
20
  export declare class MediaService {
20
21
  private readonly ttlMs;
22
+ private readonly limits;
21
23
  private readonly logger;
22
24
  private readonly store;
23
- constructor(ttlMs: number);
25
+ constructor(ttlMs: number, limits: MediaLimits);
26
+ /**
27
+ * Stores a file, or throws if the store is already full.
28
+ *
29
+ * The ceiling matters more than it looks. This map is the process's own
30
+ * heap, entries live for an hour, and nothing here bounded how many there
31
+ * could be: a loop against the upload endpoint — a bug in a publishing job
32
+ * as easily as someone with the secret — grows it until the process dies,
33
+ * and it takes the whole API down with it, not just uploads.
34
+ *
35
+ * Refusing is the right answer rather than evicting the oldest: an eviction
36
+ * would silently break a publication already in flight, and the caller
37
+ * cannot tell that its image vanished until the remote service reports a
38
+ * 404 it cannot explain.
39
+ */
24
40
  put(data: Buffer, contentType: string): string;
41
+ private bytesHeld;
25
42
  get(id: string): StoredMedia | undefined;
26
43
  /** Swept on write, which is the only moment the map can have grown. */
27
44
  private sweep;
@@ -13,9 +13,13 @@ var TikTokService = class {
13
13
  return Boolean(this.credentials.accessToken);
14
14
  }
15
15
  /**
16
- * Datos de la cuenta. Hay que llamarlo antes de publicar: TikTok lo exige
17
- * y además dice qué niveles de privacidad admite, que varían según si la
18
- * cuenta es privada, de empresa o de menor de edad.
16
+ * Datos de la cuenta: qué niveles de privacidad admite —varían según si es
17
+ * privada, de empresa o de un menor— y cuánto puede durar el video.
18
+ *
19
+ * **Obligatorio solo para publicar en directo.** Para el modo borrador no
20
+ * hace falta, y llamarlo con un token que solo tiene `video.upload` falla
21
+ * con `scope_not_authorized`, porque este endpoint pide `video.publish`.
22
+ * De ahí que `publish()` solo lo consulte cuando va a publicar directo.
19
23
  */
20
24
  async creatorInfo() {
21
25
  return this.request("/post/publish/creator_info/query/", {});
@@ -65,6 +69,19 @@ var TikTokService = class {
65
69
  }
66
70
  return inicio.publish_id;
67
71
  }
72
+ /**
73
+ * Quién es el dueño del token.
74
+ *
75
+ * Sirve para comprobar que el token vive sin publicar nada: `creatorInfo`
76
+ * no vale para eso porque pide un permiso que un token de borradores no
77
+ * tiene. Solo necesita `user.info.basic`.
78
+ */
79
+ async userInfo() {
80
+ const datos = await this.get(
81
+ "/user/info/?fields=open_id,display_name"
82
+ );
83
+ return datos.user;
84
+ }
68
85
  /** En qué va el procesado. Termina en `PUBLISH_COMPLETE` o en un fallo. */
69
86
  async status(publishId) {
70
87
  return this.request(
@@ -72,17 +89,23 @@ var TikTokService = class {
72
89
  { publish_id: publishId }
73
90
  );
74
91
  }
75
- async request(path, body) {
92
+ get(path) {
93
+ return this.llamar(path, "GET");
94
+ }
95
+ request(path, body) {
96
+ return this.llamar(path, "POST", body);
97
+ }
98
+ async llamar(path, method, body) {
76
99
  if (!this.credentials.accessToken) {
77
100
  throw new Error("TikTokService: no access token configured");
78
101
  }
79
102
  const response = await fetch(`${API}${path}`, {
80
- method: "POST",
103
+ method,
81
104
  headers: {
82
105
  Authorization: `Bearer ${this.credentials.accessToken}`,
83
106
  "Content-Type": "application/json; charset=UTF-8"
84
107
  },
85
- body: JSON.stringify(body)
108
+ ...method === "POST" ? { body: JSON.stringify(body) } : {}
86
109
  });
87
110
  const text = await response.text();
88
111
  let json;
@@ -13,9 +13,13 @@ var TikTokService = class {
13
13
  return Boolean(this.credentials.accessToken);
14
14
  }
15
15
  /**
16
- * Datos de la cuenta. Hay que llamarlo antes de publicar: TikTok lo exige
17
- * y además dice qué niveles de privacidad admite, que varían según si la
18
- * cuenta es privada, de empresa o de menor de edad.
16
+ * Datos de la cuenta: qué niveles de privacidad admite —varían según si es
17
+ * privada, de empresa o de un menor— y cuánto puede durar el video.
18
+ *
19
+ * **Obligatorio solo para publicar en directo.** Para el modo borrador no
20
+ * hace falta, y llamarlo con un token que solo tiene `video.upload` falla
21
+ * con `scope_not_authorized`, porque este endpoint pide `video.publish`.
22
+ * De ahí que `publish()` solo lo consulte cuando va a publicar directo.
19
23
  */
20
24
  async creatorInfo() {
21
25
  return this.request("/post/publish/creator_info/query/", {});
@@ -65,6 +69,19 @@ var TikTokService = class {
65
69
  }
66
70
  return inicio.publish_id;
67
71
  }
72
+ /**
73
+ * Quién es el dueño del token.
74
+ *
75
+ * Sirve para comprobar que el token vive sin publicar nada: `creatorInfo`
76
+ * no vale para eso porque pide un permiso que un token de borradores no
77
+ * tiene. Solo necesita `user.info.basic`.
78
+ */
79
+ async userInfo() {
80
+ const datos = await this.get(
81
+ "/user/info/?fields=open_id,display_name"
82
+ );
83
+ return datos.user;
84
+ }
68
85
  /** En qué va el procesado. Termina en `PUBLISH_COMPLETE` o en un fallo. */
69
86
  async status(publishId) {
70
87
  return this.request(
@@ -72,17 +89,23 @@ var TikTokService = class {
72
89
  { publish_id: publishId }
73
90
  );
74
91
  }
75
- async request(path, body) {
92
+ get(path) {
93
+ return this.llamar(path, "GET");
94
+ }
95
+ request(path, body) {
96
+ return this.llamar(path, "POST", body);
97
+ }
98
+ async llamar(path, method, body) {
76
99
  if (!this.credentials.accessToken) {
77
100
  throw new Error("TikTokService: no access token configured");
78
101
  }
79
102
  const response = await fetch(`${API}${path}`, {
80
- method: "POST",
103
+ method,
81
104
  headers: {
82
105
  Authorization: `Bearer ${this.credentials.accessToken}`,
83
106
  "Content-Type": "application/json; charset=UTF-8"
84
107
  },
85
- body: JSON.stringify(body)
108
+ ...method === "POST" ? { body: JSON.stringify(body) } : {}
86
109
  });
87
110
  const text = await response.text();
88
111
  let json;
@@ -29,10 +29,10 @@ export interface TikTokVideo {
29
29
  /**
30
30
  * Publica vídeos con la Content Posting API de TikTok.
31
31
  *
32
- * El flujo son tres llamadas y ninguna es opcional: preguntar por la cuenta,
33
- * inicializar la subida, y sondear hasta que TikTok termine de procesar. La
34
- * primera es un requisito de sus guías no una cortesía—: la API rechaza
35
- * publicar sin haber consultado antes qué admite la cuenta.
32
+ * Publicar son dos llamadas: inicializar la subida y sondear hasta que TikTok
33
+ * termine de procesar. Hay una tercera, `creatorInfo`, que es obligatoria
34
+ * **solo** para publicar en directoy que además exige `video.publish`, así
35
+ * que llamarla con un token de borradores falla.
36
36
  *
37
37
  * **Dos modos, y la diferencia importa mucho al principio.** En *Direct Post*
38
38
  * el vídeo sale en vivo, pero exige el permiso `video.publish`, que solo se
@@ -54,9 +54,13 @@ export declare class TikTokService {
54
54
  constructor(credentials: TikTokCredentials);
55
55
  get configured(): boolean;
56
56
  /**
57
- * Datos de la cuenta. Hay que llamarlo antes de publicar: TikTok lo exige
58
- * y además dice qué niveles de privacidad admite, que varían según si la
59
- * cuenta es privada, de empresa o de menor de edad.
57
+ * Datos de la cuenta: qué niveles de privacidad admite —varían según si es
58
+ * privada, de empresa o de un menor— y cuánto puede durar el video.
59
+ *
60
+ * **Obligatorio solo para publicar en directo.** Para el modo borrador no
61
+ * hace falta, y llamarlo con un token que solo tiene `video.upload` falla
62
+ * con `scope_not_authorized`, porque este endpoint pide `video.publish`.
63
+ * De ahí que `publish()` solo lo consulte cuando va a publicar directo.
60
64
  */
61
65
  creatorInfo(): Promise<TikTokCreatorInfo>;
62
66
  /**
@@ -66,10 +70,23 @@ export declare class TikTokService {
66
70
  * hace falta que la aplicación haya pasado la auditoría.
67
71
  */
68
72
  publish(video: TikTokVideo): Promise<string>;
73
+ /**
74
+ * Quién es el dueño del token.
75
+ *
76
+ * Sirve para comprobar que el token vive sin publicar nada: `creatorInfo`
77
+ * no vale para eso porque pide un permiso que un token de borradores no
78
+ * tiene. Solo necesita `user.info.basic`.
79
+ */
80
+ userInfo(): Promise<{
81
+ open_id: string;
82
+ display_name: string;
83
+ }>;
69
84
  /** En qué va el procesado. Termina en `PUBLISH_COMPLETE` o en un fallo. */
70
85
  status(publishId: string): Promise<{
71
86
  status: string;
72
87
  fail_reason?: string;
73
88
  }>;
89
+ private get;
74
90
  private request;
91
+ private llamar;
75
92
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@miguelmorales13/nestkit",
3
- "version": "0.9.0",
3
+ "version": "0.11.0",
4
4
  "private": false,
5
5
  "license": "MIT",
6
6
  "type": "module",