@adonis-agora/authkit-server 1.0.0-rc.14 → 1.0.0-rc.15

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md CHANGED
@@ -1,5 +1,13 @@
1
1
  # @adonis-agora/authkit-server
2
2
 
3
+ ## 1.0.0-rc.15
4
+
5
+ ### Patch Changes
6
+
7
+ - [#477](https://github.com/DavideCarvalho/adonis-agora-authkit/pull/477) [`c17adde`](https://github.com/DavideCarvalho/adonis-agora-authkit/commit/c17addeef2bbc80a1fea41ab5b6848b37fad7f24) Thanks [@DavideCarvalho](https://github.com/DavideCarvalho)! - The `authkit:accounts:expire-scan --warn` inactivity warning now goes through the default mailer like every other built-in email: the `mail.onAccountExpirationWarning` hook still wins, otherwise it is sent in the host locale with the host branding (new `mail.account_expiration.*` messages, en + pt-BR) instead of a hardcoded English email.
8
+
9
+ `branding` without `default` (or without `firstParty`) is now resolved once: the default brand falls back to `branding.company`, then `'AuthKit'`, the same precedence the mailer used. Screens without a known client (session expired, throttled, login/consent for an unlisted client) now show the company name and a theme accent instead of the generic fallback, and a missing `firstParty` no longer crashes the consent check. `BrandingConfigInput` makes both fields optional in `defineConfig`.
10
+
3
11
  ## 1.0.0-rc.14
4
12
 
5
13
  ### Patch Changes
package/build/index.d.ts CHANGED
@@ -75,7 +75,7 @@ export { computeAdminStats } from './src/host/admin_stats_service.js';
75
75
  export { authkitUserProvider } from './src/host/adonis_auth_user_provider.js';
76
76
  export type { BotProtectionAction, BotProtectionConfigInput, BotProtectionSetting, BotProtectionVerifyInput, BotProtectionWidget, ResolvedBotProtectionConfig, } from './src/host/bot_protection.js';
77
77
  export { botProtectionApplies, DEFAULT_BOT_TOKEN_FIELDS, extractBotToken, guardBotProtection, resolveBotProtection, resolveEffectiveBotProtection, verifyBotProtection, } from './src/host/bot_protection.js';
78
- export type { BrandingConfig, ClientBrand } from './src/host/branding.js';
78
+ export type { BrandingConfig, BrandingConfigInput, ClientBrand } from './src/host/branding.js';
79
79
  export { brandFor, isFirstParty, isFirstPartyClient } from './src/host/branding.js';
80
80
  export type { ApplicationType } from './src/host/client_metadata.js';
81
81
  export { assertClientMetadata, ClientMetadataError, redirectUriProblem, } from './src/host/client_metadata.js';
@@ -11,15 +11,20 @@
11
11
  * queries ao audit, o que é compatível com volumes moderados (<= 50k contas).
12
12
  * Para volumes maiores, o operador deve implementar um sink com agregação nativa.
13
13
  * - `--warn`: envia e-mail de aviso ("sua conta expira em N dias — faça login") via
14
- * hook `mail.onAccountExpirationWarning` + template default. Deduplicado via
15
- * audit `account.expiration_warned` — não re-avisa quem já foi avisado dentro
16
- * da janela de `warnDays` dias.
14
+ * hook `mail.onAccountExpirationWarning` ou, sem ele, o mailer default (i18n +
15
+ * branding). Deduplicado via audit `account.expiration_warned` — não re-avisa
16
+ * quem já foi avisado dentro da janela de `warnDays` dias.
17
17
  * - `--dry-run`: reporta sem enviar e-mails nem auditar.
18
18
  * - `--json`: output machine-readable.
19
19
  *
20
20
  * Designed pra cron do host (ex.: `0 2 * * * node ace authkit:accounts:expire-scan --warn`).
21
21
  */
22
22
  import type { ApplicationService } from '@adonisjs/core/types';
23
+ type ScanLogger = {
24
+ info: (m: string) => void;
25
+ warn: (m: string) => void;
26
+ error: (m: string) => void;
27
+ };
23
28
  export interface ExpireScanResult {
24
29
  /** Contas já expiradas (bloqueadas no login). */
25
30
  expired: Array<{
@@ -52,9 +57,6 @@ export declare function runExpireScan(app: ApplicationService, opts: {
52
57
  dryRun?: boolean;
53
58
  warn?: boolean;
54
59
  json?: boolean;
55
- logger: {
56
- info: (m: string) => void;
57
- warn: (m: string) => void;
58
- error: (m: string) => void;
59
- };
60
+ logger: ScanLogger;
60
61
  }): Promise<ExpireScanResult>;
62
+ export {};
@@ -11,22 +11,15 @@
11
11
  * queries ao audit, o que é compatível com volumes moderados (<= 50k contas).
12
12
  * Para volumes maiores, o operador deve implementar um sink com agregação nativa.
13
13
  * - `--warn`: envia e-mail de aviso ("sua conta expira em N dias — faça login") via
14
- * hook `mail.onAccountExpirationWarning` + template default. Deduplicado via
15
- * audit `account.expiration_warned` — não re-avisa quem já foi avisado dentro
16
- * da janela de `warnDays` dias.
14
+ * hook `mail.onAccountExpirationWarning` ou, sem ele, o mailer default (i18n +
15
+ * branding). Deduplicado via audit `account.expiration_warned` — não re-avisa
16
+ * quem já foi avisado dentro da janela de `warnDays` dias.
17
17
  * - `--dry-run`: reporta sem enviar e-mails nem auditar.
18
18
  * - `--json`: output machine-readable.
19
19
  *
20
20
  * Designed pra cron do host (ex.: `0 2 * * * node ace authkit:accounts:expire-scan --warn`).
21
21
  */
22
- var __rewriteRelativeImportExtension = (this && this.__rewriteRelativeImportExtension) || function (path, preserveJsx) {
23
- if (typeof path === "string" && /^\.\.?\//.test(path)) {
24
- return path.replace(/\.(tsx)$|((?:\.d)?)((?:\.[^./]+?)?)\.([cm]?)ts$/i, function (m, tsx, d, ext, cm) {
25
- return tsx ? preserveJsx ? ".jsx" : ".js" : d && (!ext || !cm) ? m : (d + ext + "." + cm.toLowerCase() + "js");
26
- });
27
- }
28
- return path;
29
- };
22
+ import { sendAccountExpirationWarningEmail } from '../host/default_mailer.js';
30
23
  import { RuntimeSettings, settingsLocation } from '../host/runtime_settings.js';
31
24
  import { resolveEffectiveAccountExpiration } from '../host/runtime_toggles.js';
32
25
  import { ADMIN_LIST_DEFAULT_SIZE, LIST_FIRST_PAGE } from '../pagination.js';
@@ -114,7 +107,7 @@ export async function runExpireScan(app, opts) {
114
107
  }
115
108
  else {
116
109
  // Envia e-mail de aviso.
117
- await sendWarnEmail(cfg, account, daysUntilExpiry);
110
+ await sendWarnEmail(cfg, account, daysUntilExpiry, logger);
118
111
  // Audita para deduplicação.
119
112
  await audit.record({
120
113
  type: 'account.expiration_warned',
@@ -199,31 +192,38 @@ async function wasWarnedRecently(audit, accountId, warnDays) {
199
192
  return false;
200
193
  }
201
194
  }
202
- /** Envia e-mail de aviso de expiração iminente. Best-effort (silencioso em caso de erro). */
203
- async function sendWarnEmail(cfg, account, expiresInDays) {
195
+ /**
196
+ * Envia o aviso de expiração iminente pelo MESMO caminho dos outros e-mails da lib: o hook
197
+ * `mail.onAccountExpirationWarning` do host tem prioridade; sem ele, o mailer default
198
+ * (`@adonisjs/mail`) com o locale e o branding do config resolvido. Best-effort: nunca
199
+ * propaga erros de e-mail (o scan segue para a próxima conta).
200
+ */
201
+ async function sendWarnEmail(cfg, account, expiresInDays, logger) {
204
202
  try {
205
203
  if (cfg.mail?.onAccountExpirationWarning) {
206
204
  await cfg.mail.onAccountExpirationWarning({ email: account.email, expiresInDays });
207
205
  return;
208
206
  }
209
- // Default: tenta via @adonisjs/mail se disponível.
210
- const mailSpecifier = '@adonisjs/mail/services/main';
211
- const mailMod = await import(__rewriteRelativeImportExtension(mailSpecifier)).catch(() => null);
212
- const mail = mailMod?.default ?? null;
213
- if (!mail)
214
- return; // Sem mail configurado — silêncio.
215
- await mail.send((message) => {
216
- message
217
- .to(account.email)
218
- .subject('Your account will be deactivated due to inactivity')
219
- .html(`<p>Your account will be deactivated in <strong>${expiresInDays} day(s)</strong> due to inactivity. Sign in to keep your account active.</p>`)
220
- .text(`Your account will be deactivated in ${expiresInDays} day(s) due to inactivity. Sign in to keep your account active.`);
207
+ await sendAccountExpirationWarningEmail({ logger: mailLogger(logger) }, cfg, {
208
+ email: account.email,
209
+ expiresInDays,
221
210
  });
222
211
  }
223
212
  catch {
224
213
  // Best-effort: nunca propaga erros de e-mail.
225
214
  }
226
215
  }
216
+ /** Adapta o logger do Ace (`info(msg)`) ao formato pino (`info(obj, msg)`) do mailer. */
217
+ function mailLogger(logger) {
218
+ const format = (obj, msg) => typeof msg === 'string' ? `${msg} ${JSON.stringify(obj, errorReplacer)}` : String(obj);
219
+ return {
220
+ info: (obj, msg) => logger.info(format(obj, msg)),
221
+ error: (obj, msg) => logger.error(format(obj, msg)),
222
+ };
223
+ }
224
+ function errorReplacer(_key, value) {
225
+ return value instanceof Error ? value.message : value;
226
+ }
227
227
  function emptyResult(dryRun, inactiveDays, warnDays, auditSupported = false) {
228
228
  return {
229
229
  expired: [],
@@ -8,7 +8,7 @@ import type { AuditSink } from './audit/audit_sink.js';
8
8
  import type { ConsoleAuthorize } from './console_authorize.js';
9
9
  import { type EventsConfigInput } from './events/dispatcher.js';
10
10
  import { type BotProtectionConfigInput, type ResolvedBotProtectionConfig } from './host/bot_protection.js';
11
- import { type BrandingConfig } from './host/branding.js';
11
+ import { type BrandingConfig, type BrandingConfigInput } from './host/branding.js';
12
12
  import { type PolicyRouteOption } from './host/config_locks.js';
13
13
  import type { CustomLoginMethods } from './host/custom_login.js';
14
14
  import type { CustomMfaMethods } from './host/custom_mfa.js';
@@ -959,8 +959,8 @@ export interface AuthServerConfigInput {
959
959
  * {@link InteractionRecoveryConfigInput}.
960
960
  */
961
961
  interactionRecovery?: InteractionRecoveryConfigInput;
962
- /** Configuração de branding por cliente. */
963
- branding?: BrandingConfig;
962
+ /** Configuração de branding por cliente (`default`/`firstParty` opcionais). */
963
+ branding?: BrandingConfigInput;
964
964
  /**
965
965
  * Allowlist dos clients FIRST-PARTY: quem recebe as claims de AUTORIZAÇÃO
966
966
  * (`<globalRolesClaim>`, `org_id`, `org_slug`, `org_role`) nos tokens.
@@ -15,13 +15,26 @@ export interface BrandingConfig {
15
15
  firstParty: string[];
16
16
  audienceLabels?: Record<string, string>;
17
17
  }
18
+ /**
19
+ * O que o host declara em `defineConfig({ branding })`. `default` e `firstParty` são
20
+ * opcionais: sem `default` a marca de quem não tem client conhecido vem da `company`
21
+ * (ver {@link resolveDefaultBrand}); sem `firstParty`, nenhum client pula o consent.
22
+ */
23
+ export type BrandingConfigInput = Omit<BrandingConfig, 'default' | 'firstParty'> & Partial<Pick<BrandingConfig, 'default' | 'firstParty'>>;
18
24
  /**
19
25
  * Resolve a config de branding: quando o host não declara `branding`, cai no
20
26
  * {@link DEFAULT_BRANDING} acima (tema neutro, sem clients customizados) em
21
27
  * vez de deixar `cfg.branding` como `undefined` — o que fazia a MENOR config
22
28
  * documentada (sem branding) quebrar a primeira tela de login/consent/signup.
23
29
  */
24
- export declare function resolveBranding(input: BrandingConfig | undefined): BrandingConfig;
30
+ export declare function resolveBranding(input: BrandingConfigInput | undefined): BrandingConfig;
31
+ /**
32
+ * A marca default de um host — o ÚNICO lugar que decide o nome/cor de quem não tem
33
+ * client conhecido (telas sem client, e-mails). Precedência do nome:
34
+ * `branding.default.appName` → `branding.company` → `'AuthKit'`; as cores caem no
35
+ * tema neutro. Tolera `branding` parcial/ausente (configs crus, fixtures).
36
+ */
37
+ export declare function resolveDefaultBrand(branding: Partial<BrandingConfigInput> | undefined): BrandingConfig['default'];
25
38
  export declare function isFirstParty(cfg: BrandingConfig, clientId: string | undefined): boolean;
26
39
  /**
27
40
  * O client é FIRST-PARTY, ou seja, recebe as claims de autorização
@@ -27,7 +27,36 @@ const DEFAULT_BRANDING = {
27
27
  * documentada (sem branding) quebrar a primeira tela de login/consent/signup.
28
28
  */
29
29
  export function resolveBranding(input) {
30
- return input ?? DEFAULT_BRANDING;
30
+ if (!input)
31
+ return DEFAULT_BRANDING;
32
+ // Hosts declaram na prática só `company` + `clients` (sem `default`, às vezes sem
33
+ // `firstParty`). Sem normalizar, `brandFor` devolvia uma marca SEM `appName`/`accent`
34
+ // para todo fluxo sem client conhecido (console de conta, recuperação, throttle) — as
35
+ // telas caíam no fallback genérico enquanto os e-mails já usavam a `company` — e
36
+ // `isFirstParty` estourava em `firstParty.includes`.
37
+ return {
38
+ ...input,
39
+ clients: input.clients ?? {},
40
+ default: resolveDefaultBrand(input),
41
+ firstParty: input.firstParty ?? [],
42
+ };
43
+ }
44
+ /**
45
+ * A marca default de um host — o ÚNICO lugar que decide o nome/cor de quem não tem
46
+ * client conhecido (telas sem client, e-mails). Precedência do nome:
47
+ * `branding.default.appName` → `branding.company` → `'AuthKit'`; as cores caem no
48
+ * tema neutro. Tolera `branding` parcial/ausente (configs crus, fixtures).
49
+ */
50
+ export function resolveDefaultBrand(branding) {
51
+ const declared = branding?.default;
52
+ const neutral = DEFAULT_BRANDING.default;
53
+ return {
54
+ ...declared,
55
+ appName: declared?.appName || branding?.company || neutral.appName,
56
+ accent: declared?.accent || neutral.accent,
57
+ accentSoft: declared?.accentSoft || declared?.accent || neutral.accentSoft,
58
+ tagline: declared?.tagline ?? neutral.tagline,
59
+ };
31
60
  }
32
61
  export function isFirstParty(cfg, clientId) {
33
62
  return !!clientId && cfg.firstParty.includes(clientId);
@@ -155,6 +155,19 @@ export declare function sendOtpUnlockEmail(ctx: HttpContext, data: {
155
155
  email: string;
156
156
  unlockUrl: string;
157
157
  }, cfg?: ResolvedServerConfig): Promise<void>;
158
+ /**
159
+ * Envia o aviso "sua conta será desativada por inatividade em N dias" pelo mailer
160
+ * default do host, com branding e i18n. Chamado pelo `authkit:accounts:expire-scan --warn`
161
+ * quando o host não define `mail.onAccountExpirationWarning` — fora de uma request, então
162
+ * aceita só um `{ logger }` (pino-like) no lugar do `HttpContext`, e o config resolvido
163
+ * vem do comando. Best-effort: sem `@adonisjs/mail` loga o aviso; nunca lança.
164
+ */
165
+ export declare function sendAccountExpirationWarningEmail(ctx: HttpContext | {
166
+ logger: Pick<HttpContext['logger'], 'info' | 'error'>;
167
+ }, cfg: ResolvedServerConfig, data: {
168
+ email: string;
169
+ expiresInDays: number;
170
+ }): Promise<void>;
158
171
  /**
159
172
  * Sends the organization invitation email through the host default mailer.
160
173
  *
@@ -8,6 +8,7 @@ var __rewriteRelativeImportExtension = (this && this.__rewriteRelativeImportExte
8
8
  };
9
9
  import { getBootedApp } from '../../services/booted_app.js';
10
10
  import { accountPath } from './account_paths.js';
11
+ import { resolveDefaultBrand } from './branding.js';
11
12
  import { renderTransactionalEmail } from './email_templates.js';
12
13
  import { resolveMessages, translate } from './i18n.js';
13
14
  import { authkitOrigin } from './origin.js';
@@ -130,12 +131,11 @@ async function resolveMailSettings(ctx, cfg) {
130
131
  return { brand: { appName: 'AuthKit' }, messages: resolveMessages(), locale: 'en' };
131
132
  }
132
133
  const branding = resolved.branding;
134
+ const defaultBrand = resolveDefaultBrand(branding);
133
135
  return {
134
136
  brand: {
135
- // `branding.default` é opcional na prática (hosts declaram só `company` + `clients`):
136
- // sem ele, o nome do app é a `company`.
137
- appName: branding?.default?.appName || branding?.company || 'AuthKit',
138
- accent: branding?.default?.accent,
137
+ appName: defaultBrand.appName,
138
+ accent: defaultBrand.accent,
139
139
  company: branding?.company,
140
140
  },
141
141
  messages: resolved.messages ?? resolveMessages(),
@@ -549,6 +549,39 @@ export async function sendOtpUnlockEmail(ctx, data, cfg) {
549
549
  ctx.logger.error({ err: error, email: data.email }, 'authkit: falha ao enviar e-mail de desbloqueio OTP');
550
550
  }
551
551
  }
552
+ /**
553
+ * Envia o aviso "sua conta será desativada por inatividade em N dias" pelo mailer
554
+ * default do host, com branding e i18n. Chamado pelo `authkit:accounts:expire-scan --warn`
555
+ * quando o host não define `mail.onAccountExpirationWarning` — fora de uma request, então
556
+ * aceita só um `{ logger }` (pino-like) no lugar do `HttpContext`, e o config resolvido
557
+ * vem do comando. Best-effort: sem `@adonisjs/mail` loga o aviso; nunca lança.
558
+ */
559
+ export async function sendAccountExpirationWarningEmail(ctx, cfg, data) {
560
+ const httpCtx = ctx;
561
+ try {
562
+ const settings = await resolveMailSettings(httpCtx, cfg);
563
+ const { brand, messages: t, locale } = settings;
564
+ const origin = authkitOrigin(cfg);
565
+ const content = renderTransactionalEmail({
566
+ brand,
567
+ locale,
568
+ linkFallback: translate(t, 'mail.common.link_fallback'),
569
+ subject: translate(t, 'mail.account_expiration.subject', { days: data.expiresInDays }),
570
+ heading: translate(t, 'mail.account_expiration.heading', { days: data.expiresInDays }),
571
+ intro: translate(t, 'mail.account_expiration.intro', { days: data.expiresInDays }),
572
+ ctaLabel: translate(t, 'mail.account_expiration.cta'),
573
+ ctaUrl: `${origin}${accountPath('login')}`,
574
+ footnote: translate(t, 'mail.account_expiration.fallback'),
575
+ });
576
+ const sent = await sendEmail(settings, data.email, content);
577
+ if (!sent) {
578
+ httpCtx.logger?.info({ email: data.email, expiresInDays: data.expiresInDays }, 'authkit: aviso de expiração por inatividade (dev — @adonisjs/mail ausente)');
579
+ }
580
+ }
581
+ catch (error) {
582
+ httpCtx.logger?.error({ err: error, email: data.email }, 'authkit: falha ao enviar aviso de expiração por inatividade');
583
+ }
584
+ }
552
585
  /**
553
586
  * Sends the organization invitation email through the host default mailer.
554
587
  *
@@ -629,6 +629,11 @@ export declare const DEFAULT_MESSAGES: {
629
629
  'mail.otp_unlock.intro': string;
630
630
  'mail.otp_unlock.cta': string;
631
631
  'mail.otp_unlock.fallback': string;
632
+ 'mail.account_expiration.subject': string;
633
+ 'mail.account_expiration.heading': string;
634
+ 'mail.account_expiration.intro': string;
635
+ 'mail.account_expiration.cta': string;
636
+ 'mail.account_expiration.fallback': string;
632
637
  'account.confirm.page_title': string;
633
638
  'account.confirm.title': string;
634
639
  'account.confirm.intro': string;
@@ -1422,6 +1427,11 @@ export declare const PT_BR_MESSAGES: {
1422
1427
  'mail.otp_unlock.intro': string;
1423
1428
  'mail.otp_unlock.cta': string;
1424
1429
  'mail.otp_unlock.fallback': string;
1430
+ 'mail.account_expiration.subject': string;
1431
+ 'mail.account_expiration.heading': string;
1432
+ 'mail.account_expiration.intro': string;
1433
+ 'mail.account_expiration.cta': string;
1434
+ 'mail.account_expiration.fallback': string;
1425
1435
  'otp_unlock.page_title': string;
1426
1436
  'otp_unlock.ok_title': string;
1427
1437
  'otp_unlock.ok_body': string;
@@ -684,6 +684,12 @@ export const DEFAULT_MESSAGES = {
684
684
  'mail.otp_unlock.intro': 'Your two-factor authentication factor has been locked due to too many failed attempts. Click the button below to unlock it.',
685
685
  'mail.otp_unlock.cta': 'Unlock two-factor',
686
686
  'mail.otp_unlock.fallback': 'If you did not attempt to sign in, your account may be at risk — change your password immediately.',
687
+ // E-mail de aviso de expiração por inatividade (`authkit:accounts:expire-scan --warn`).
688
+ 'mail.account_expiration.subject': 'Your account will be deactivated due to inactivity',
689
+ 'mail.account_expiration.heading': 'Your account will be deactivated soon',
690
+ 'mail.account_expiration.intro': 'Your account will be deactivated in {days} day(s) due to inactivity. Sign in to keep your account active.',
691
+ 'mail.account_expiration.cta': 'Sign in',
692
+ 'mail.account_expiration.fallback': 'If you no longer use this account, you can ignore this email and it will be deactivated.',
687
693
  // Tela de desbloqueio OTP (/auth/otp-unlock/:token).
688
694
  // Sudo mode (confirm identity — /account/confirm).
689
695
  'account.confirm.page_title': 'Confirm your identity',
@@ -1561,6 +1567,12 @@ export const PT_BR_MESSAGES = {
1561
1567
  'mail.otp_unlock.intro': 'Sua verificação em duas etapas foi bloqueada após muitas tentativas incorretas. Clique no botão abaixo para desbloquear.',
1562
1568
  'mail.otp_unlock.cta': 'Desbloquear verificação',
1563
1569
  'mail.otp_unlock.fallback': 'Se você não tentou fazer login, sua conta pode estar em risco — altere sua senha imediatamente.',
1570
+ // E-mail de aviso de expiração por inatividade (pt-BR).
1571
+ 'mail.account_expiration.subject': 'Sua conta será desativada por inatividade',
1572
+ 'mail.account_expiration.heading': 'Sua conta será desativada em breve',
1573
+ 'mail.account_expiration.intro': 'Sua conta será desativada em {days} dia(s) por inatividade. Entre na sua conta para mantê-la ativa.',
1574
+ 'mail.account_expiration.cta': 'Entrar',
1575
+ 'mail.account_expiration.fallback': 'Se você não usa mais esta conta, pode ignorar este e-mail e ela será desativada.',
1564
1576
  // Tela de desbloqueio OTP (pt-BR).
1565
1577
  'otp_unlock.page_title': 'Desbloqueio da verificação em duas etapas',
1566
1578
  'otp_unlock.ok_title': 'Verificação em duas etapas desbloqueada',
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@adonis-agora/authkit-server",
3
- "version": "1.0.0-rc.14",
3
+ "version": "1.0.0-rc.15",
4
4
  "description": "AdonisJS OIDC/OAuth2 provider (Identity Provider) toolkit: ejectable auth server with sessions, rate-limiting, MFA/TOTP, audit log, federated logout and OpenTelemetry metrics.",
5
5
  "license": "MIT",
6
6
  "author": "dudousxd",