@allanfsouza/aether-sdk 2.4.6 → 2.4.9

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,221 @@
1
+ // src/tenant-auth.ts
2
+ // [FEATURE] Módulo de Autenticação de Tenants para SDK
3
+ // Permite autenticação de end-users em projetos Aether
4
+ // Compatible with Firebase Auth-style usage
5
+ // ============================================================================
6
+ // MODULE
7
+ // ============================================================================
8
+ /**
9
+ * Módulo de Autenticação de Tenants
10
+ *
11
+ * Permite que usuários finais (clientes) de projetos Aether
12
+ * se registrem, façam login e gerenciem seus perfis.
13
+ *
14
+ * Os dados são isolados por projeto (tabela prj_{id}_users).
15
+ *
16
+ * @example
17
+ * ```typescript
18
+ * const aether = new PlataformaClient({ ... });
19
+ *
20
+ * // Registrar usuário no projeto
21
+ * const { user, error } = await aether.tenantAuth.signUp('project-id', {
22
+ * email: 'user@example.com',
23
+ * password: '123456',
24
+ * name: 'John Doe',
25
+ * });
26
+ *
27
+ * // Login
28
+ * const { accessToken, user } = await aether.tenantAuth.signIn('project-id', {
29
+ * email: 'user@example.com',
30
+ * password: '123456',
31
+ * });
32
+ *
33
+ * // Obter perfil
34
+ * const profile = await aether.tenantAuth.getProfile('project-id');
35
+ * ```
36
+ */
37
+ export class TenantAuthModule {
38
+ constructor(client, http) {
39
+ // Armazena o token do tenant atual em memória
40
+ this.currentToken = null;
41
+ this.currentUser = null;
42
+ this.client = client;
43
+ this.http = http;
44
+ }
45
+ // ==========================================================================
46
+ // MÉTODOS PRINCIPAIS
47
+ // ==========================================================================
48
+ /**
49
+ * Registra um novo usuário tenant no projeto.
50
+ * A tabela de usuários é criada automaticamente se não existir.
51
+ *
52
+ * @param projectId - ID do projeto
53
+ * @param credentials - Email, senha, nome e dados opcionais
54
+ */
55
+ async register(projectId, credentials) {
56
+ const { data } = await this.http.post("/auth/tenant/register", {
57
+ projectId,
58
+ ...credentials,
59
+ });
60
+ return data;
61
+ }
62
+ /**
63
+ * Realiza login de usuário tenant.
64
+ * Armazena o token para uso em requisições subsequentes.
65
+ *
66
+ * @param projectId - ID do projeto
67
+ * @param credentials - Email e senha
68
+ */
69
+ async login(projectId, credentials) {
70
+ const { data } = await this.http.post("/auth/tenant/login", {
71
+ projectId,
72
+ ...credentials,
73
+ });
74
+ // Armazena token e usuário
75
+ this.currentToken = data.accessToken;
76
+ this.currentUser = data.user;
77
+ return data;
78
+ }
79
+ /**
80
+ * Solicita reset de senha.
81
+ * Retorna mensagem genérica para prevenir enumeração de usuários.
82
+ *
83
+ * @param projectId - ID do projeto
84
+ * @param email - Email do usuário
85
+ */
86
+ async forgotPassword(projectId, email) {
87
+ const { data } = await this.http.post("/auth/tenant/forgot-password", {
88
+ projectId,
89
+ email,
90
+ });
91
+ return data;
92
+ }
93
+ /**
94
+ * Redefine a senha usando o token recebido por email.
95
+ *
96
+ * @param projectId - ID do projeto
97
+ * @param email - Email do usuário
98
+ * @param token - Token de reset (recebido por email)
99
+ * @param newPassword - Nova senha
100
+ */
101
+ async resetPassword(projectId, email, token, newPassword) {
102
+ const { data } = await this.http.post("/auth/tenant/reset-password", {
103
+ projectId,
104
+ email,
105
+ token,
106
+ newPassword,
107
+ });
108
+ return data;
109
+ }
110
+ /**
111
+ * Obtém o perfil do usuário autenticado.
112
+ * Requer que login() tenha sido chamado antes.
113
+ *
114
+ * @param projectId - ID do projeto (opcional, usa do token se não fornecido)
115
+ */
116
+ async getProfile(projectId) {
117
+ const headers = this.currentToken
118
+ ? { Authorization: `Bearer ${this.currentToken}` }
119
+ : {};
120
+ const { data } = await this.http.get("/auth/tenant/me", { headers });
121
+ this.currentUser = data.user;
122
+ return data.user;
123
+ }
124
+ /**
125
+ * Atualiza o perfil do usuário autenticado.
126
+ *
127
+ * @param projectId - ID do projeto
128
+ * @param updates - Campos a atualizar (name, data)
129
+ */
130
+ async updateProfile(projectId, updates) {
131
+ const headers = this.currentToken
132
+ ? { Authorization: `Bearer ${this.currentToken}` }
133
+ : {};
134
+ const { data } = await this.http.put("/auth/tenant/profile", {
135
+ projectId,
136
+ ...updates,
137
+ }, { headers });
138
+ this.currentUser = data.user;
139
+ return data;
140
+ }
141
+ /**
142
+ * Faz logout do tenant (limpa token local).
143
+ */
144
+ logout() {
145
+ this.currentToken = null;
146
+ this.currentUser = null;
147
+ }
148
+ // ==========================================================================
149
+ // MÉTODOS ESTILO SUPABASE/FIREBASE
150
+ // ==========================================================================
151
+ /**
152
+ * Alias para register com retorno { user, error }
153
+ */
154
+ async signUp(projectId, credentials) {
155
+ try {
156
+ const { user } = await this.register(projectId, credentials);
157
+ return { user, error: null };
158
+ }
159
+ catch (err) {
160
+ const axiosError = err;
161
+ return {
162
+ user: null,
163
+ error: axiosError.response?.data?.error ||
164
+ axiosError.response?.data?.message ||
165
+ err.message,
166
+ };
167
+ }
168
+ }
169
+ /**
170
+ * Alias para login com retorno { user, accessToken, error }
171
+ */
172
+ async signIn(projectId, credentials) {
173
+ try {
174
+ const { user, accessToken } = await this.login(projectId, credentials);
175
+ return { user, accessToken, error: null };
176
+ }
177
+ catch (err) {
178
+ const axiosError = err;
179
+ return {
180
+ user: null,
181
+ accessToken: null,
182
+ error: axiosError.response?.data?.error ||
183
+ axiosError.response?.data?.message ||
184
+ err.message,
185
+ };
186
+ }
187
+ }
188
+ /**
189
+ * Alias para logout
190
+ */
191
+ signOut() {
192
+ this.logout();
193
+ }
194
+ // ==========================================================================
195
+ // ACCESSORS
196
+ // ==========================================================================
197
+ /**
198
+ * Retorna o token atual do tenant
199
+ */
200
+ getToken() {
201
+ return this.currentToken;
202
+ }
203
+ /**
204
+ * Define o token do tenant (útil para restaurar sessão)
205
+ */
206
+ setToken(token) {
207
+ this.currentToken = token;
208
+ }
209
+ /**
210
+ * Retorna o usuário atual do tenant
211
+ */
212
+ getCurrentUser() {
213
+ return this.currentUser;
214
+ }
215
+ /**
216
+ * Verifica se há um usuário autenticado
217
+ */
218
+ isAuthenticated() {
219
+ return this.currentToken !== null;
220
+ }
221
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@allanfsouza/aether-sdk",
3
- "version": "2.4.6",
3
+ "version": "2.4.9",
4
4
  "description": "SDK do Cliente para a Plataforma Aether",
5
5
  "main": "dist/index.js",
6
6
  "types": "dist/index.d.ts",
@@ -32,4 +32,4 @@
32
32
  "@types/ws": "^8.5.10",
33
33
  "typescript": "^5.3.0"
34
34
  }
35
- }
35
+ }
@@ -1,24 +1,135 @@
1
1
  // src/http-client.ts
2
+ // [MELHORIA] SDK com Retry Automático e Exponential Backoff
3
+ // Data: 09/12/2025
4
+
2
5
  import axios, {
3
6
  type AxiosInstance,
4
7
  type AxiosError,
8
+ type AxiosRequestConfig,
5
9
  type InternalAxiosRequestConfig,
6
10
  } from "axios";
7
11
  import type { PlataformaClient } from "./index.js";
8
12
  import { handleAxiosError } from "./errors.js";
9
13
 
10
- // Flag para evitar múltiplos refreshes simultâneos
11
- let isRefreshing = false;
14
+ // =============================================================================
15
+ // CONFIGURAÇÕES DE RETRY
16
+ // =============================================================================
17
+
18
+ export interface RetryConfig {
19
+ /** Número máximo de tentativas (padrão: 3) */
20
+ maxRetries: number;
21
+ /** Delay base em ms para exponential backoff (padrão: 1000) */
22
+ baseDelay: number;
23
+ /** Delay máximo em ms (padrão: 30000) */
24
+ maxDelay: number;
25
+ /** Status codes que devem ser retried (padrão: [408, 429, 500, 502, 503, 504]) */
26
+ retryableStatuses: number[];
27
+ /** Métodos HTTP que podem ser retried (padrão: GET, HEAD, OPTIONS, PUT, DELETE) */
28
+ retryableMethods: string[];
29
+ /** Habilita retry para erros de rede (padrão: true) */
30
+ retryOnNetworkError: boolean;
31
+ /** Callback chamado antes de cada retry */
32
+ onRetry?: (retryCount: number, error: AxiosError, config: AxiosRequestConfig) => void;
33
+ }
34
+
35
+ export const DEFAULT_RETRY_CONFIG: RetryConfig = {
36
+ maxRetries: 3,
37
+ baseDelay: 1000,
38
+ maxDelay: 30000,
39
+ retryableStatuses: [408, 429, 500, 502, 503, 504],
40
+ retryableMethods: ["GET", "HEAD", "OPTIONS", "PUT", "DELETE"],
41
+ retryOnNetworkError: true,
42
+ };
43
+
44
+ // =============================================================================
45
+ // HELPERS
46
+ // =============================================================================
47
+
48
+ /**
49
+ * Calcula o delay com exponential backoff + jitter
50
+ */
51
+ function calculateDelay(retryCount: number, config: RetryConfig): number {
52
+ // Exponential: baseDelay * 2^retryCount
53
+ const exponentialDelay = config.baseDelay * Math.pow(2, retryCount);
54
+
55
+ // Adiciona jitter (±25%) para evitar thundering herd
56
+ const jitter = exponentialDelay * 0.25 * (Math.random() * 2 - 1);
57
+
58
+ // Limita ao maxDelay
59
+ return Math.min(exponentialDelay + jitter, config.maxDelay);
60
+ }
61
+
62
+ /**
63
+ * Verifica se o erro é retryable
64
+ */
65
+ function isRetryable(error: AxiosError, config: RetryConfig): boolean {
66
+ // Erro de rede (sem response)
67
+ if (!error.response && config.retryOnNetworkError) {
68
+ // Verifica se é erro de rede real (não cancelamento)
69
+ if (error.code === "ECONNABORTED" || error.code === "ETIMEDOUT" ||
70
+ error.code === "ENOTFOUND" || error.code === "ENETUNREACH" ||
71
+ error.message === "Network Error") {
72
+ return true;
73
+ }
74
+ }
12
75
 
13
- // Fila de requisições aguardando o refresh
76
+ // Verifica status code
77
+ const status = error.response?.status;
78
+ if (status && config.retryableStatuses.includes(status)) {
79
+ return true;
80
+ }
81
+
82
+ return false;
83
+ }
84
+
85
+ /**
86
+ * Verifica se o método HTTP pode ser retried
87
+ */
88
+ function isMethodRetryable(method: string | undefined, config: RetryConfig): boolean {
89
+ if (!method) return false;
90
+ return config.retryableMethods.includes(method.toUpperCase());
91
+ }
92
+
93
+ /**
94
+ * Extrai Retry-After header (em ms)
95
+ */
96
+ function getRetryAfterMs(error: AxiosError): number | null {
97
+ const retryAfter = error.response?.headers?.["retry-after"];
98
+
99
+ if (!retryAfter) return null;
100
+
101
+ // Se for número, é segundos
102
+ const seconds = parseInt(retryAfter, 10);
103
+ if (!isNaN(seconds)) {
104
+ return seconds * 1000;
105
+ }
106
+
107
+ // Se for data HTTP, calcula diferença
108
+ const date = new Date(retryAfter);
109
+ if (!isNaN(date.getTime())) {
110
+ return Math.max(0, date.getTime() - Date.now());
111
+ }
112
+
113
+ return null;
114
+ }
115
+
116
+ /**
117
+ * Aguarda um tempo determinado
118
+ */
119
+ function sleep(ms: number): Promise<void> {
120
+ return new Promise(resolve => setTimeout(resolve, ms));
121
+ }
122
+
123
+ // =============================================================================
124
+ // FLAGS GLOBAIS (Refresh Token)
125
+ // =============================================================================
126
+
127
+ let isRefreshing = false;
14
128
  let failedQueue: Array<{
15
129
  resolve: (token: string) => void;
16
130
  reject: (error: any) => void;
17
131
  }> = [];
18
132
 
19
- /**
20
- * Processa a fila de requisições após o refresh.
21
- */
22
133
  const processQueue = (error: any, token: string | null = null) => {
23
134
  failedQueue.forEach((prom) => {
24
135
  if (error) {
@@ -30,15 +141,35 @@ const processQueue = (error: any, token: string | null = null) => {
30
141
  failedQueue = [];
31
142
  };
32
143
 
144
+ // =============================================================================
145
+ // EXTENDED CONFIG TYPE
146
+ // =============================================================================
147
+
148
+ interface ExtendedAxiosRequestConfig extends InternalAxiosRequestConfig {
149
+ _retry?: boolean;
150
+ _retryCount?: number;
151
+ }
152
+
153
+ // =============================================================================
154
+ // CRIAR HTTP CLIENT
155
+ // =============================================================================
156
+
33
157
  /**
34
- * Cria uma instância do Axios pré-configurada.
35
- * - Injeta automaticamente headers de Auth e Projeto.
36
- * - Renova token automaticamente quando expira (401).
37
- * - Converte erros em AetherError.
158
+ * Cria uma instância do Axios pré-configurada com:
159
+ * - Injeção automática de headers (Auth, Project)
160
+ * - Refresh automático de token (401)
161
+ * - Retry automático com exponential backoff
162
+ * - Tratamento de erros padronizado
38
163
  */
39
- export function createHttpClient(client: PlataformaClient): AxiosInstance {
164
+ export function createHttpClient(
165
+ client: PlataformaClient,
166
+ retryConfig: Partial<RetryConfig> = {}
167
+ ): AxiosInstance {
168
+ const config: RetryConfig = { ...DEFAULT_RETRY_CONFIG, ...retryConfig };
169
+
40
170
  const http = axios.create({
41
171
  baseURL: `${client.apiUrl}/v1`,
172
+ timeout: 30000, // 30 segundos
42
173
  headers: {
43
174
  "Content-Type": "application/json",
44
175
  },
@@ -48,47 +179,106 @@ export function createHttpClient(client: PlataformaClient): AxiosInstance {
48
179
  // INTERCEPTOR DE REQUEST
49
180
  // Injeta token e projectId em todas as requisições
50
181
  // ===========================================================================
51
- http.interceptors.request.use((config) => {
182
+ http.interceptors.request.use((reqConfig) => {
52
183
  const token = client.getToken();
53
184
  if (token) {
54
- config.headers.Authorization = `Bearer ${token}`;
185
+ reqConfig.headers.Authorization = `Bearer ${token}`;
55
186
  }
56
187
 
57
188
  if (client.projectId) {
58
- config.headers["X-Project-ID"] = client.projectId;
189
+ reqConfig.headers["X-Project-ID"] = client.projectId;
190
+ }
191
+
192
+ // Inicializa contador de retry
193
+ if ((reqConfig as ExtendedAxiosRequestConfig)._retryCount === undefined) {
194
+ (reqConfig as ExtendedAxiosRequestConfig)._retryCount = 0;
59
195
  }
60
196
 
61
- return config;
197
+ return reqConfig;
62
198
  });
63
199
 
64
200
  // ===========================================================================
65
201
  // INTERCEPTOR DE RESPONSE
66
- // Detecta 401 e tenta refresh automático do token
202
+ // 1. Retry automático para erros de rede/servidor
203
+ // 2. Refresh automático de token (401)
67
204
  // ===========================================================================
68
205
  http.interceptors.response.use(
69
206
  (response) => response,
70
207
  async (error: AxiosError) => {
71
- const originalRequest = error.config as InternalAxiosRequestConfig & {
72
- _retry?: boolean;
73
- };
208
+ const originalRequest = error.config as ExtendedAxiosRequestConfig;
209
+
210
+ if (!originalRequest) {
211
+ return handleAxiosError(error);
212
+ }
213
+
214
+ // -----------------------------------------------------------------
215
+ // PARTE 1: RETRY AUTOMÁTICO (erros de rede e servidor)
216
+ // -----------------------------------------------------------------
217
+ const retryCount = originalRequest._retryCount || 0;
218
+ const canRetry = retryCount < config.maxRetries;
219
+ const isRetryableError = isRetryable(error, config);
220
+ const isRetryableMethod = isMethodRetryable(originalRequest.method, config);
221
+
222
+ // Não faz retry para 401 (tratado pelo refresh token)
223
+ const is401 = error.response?.status === 401;
224
+
225
+ if (canRetry && isRetryableError && isRetryableMethod && !is401) {
226
+ originalRequest._retryCount = retryCount + 1;
227
+
228
+ // Calcula delay (respeita Retry-After se presente)
229
+ const delay = getRetryAfterMs(error) || calculateDelay(retryCount, config);
230
+
231
+ // Callback de retry (para logging/métricas)
232
+ if (config.onRetry) {
233
+ config.onRetry(originalRequest._retryCount, error, originalRequest);
234
+ }
235
+
236
+ // Emite evento para monitoramento
237
+ if (typeof window !== "undefined") {
238
+ window.dispatchEvent(new CustomEvent("aether:retry", {
239
+ detail: {
240
+ attempt: originalRequest._retryCount,
241
+ maxRetries: config.maxRetries,
242
+ delay,
243
+ url: originalRequest.url,
244
+ method: originalRequest.method,
245
+ status: error.response?.status,
246
+ error: error.message,
247
+ }
248
+ }));
249
+ }
250
+
251
+ // Log em desenvolvimento
252
+ if (process.env.NODE_ENV === "development") {
253
+ console.log(
254
+ `[Aether SDK] Retry ${originalRequest._retryCount}/${config.maxRetries}`,
255
+ `em ${delay}ms para ${originalRequest.method} ${originalRequest.url}`,
256
+ `(${error.response?.status || error.message})`
257
+ );
258
+ }
259
+
260
+ // Aguarda e refaz a requisição
261
+ await sleep(delay);
262
+ return http(originalRequest);
263
+ }
74
264
 
75
- // Verifica se é erro 401 (não autorizado) e não é retry
265
+ // -----------------------------------------------------------------
266
+ // PARTE 2: REFRESH TOKEN (erro 401)
267
+ // -----------------------------------------------------------------
76
268
  const isUnauthorized = error.response?.status === 401;
77
- const isRetry = originalRequest?._retry;
78
- const isAuthRoute = originalRequest?.url?.includes("/auth/");
269
+ const isRetry = originalRequest._retry;
270
+ const isAuthRoute = originalRequest.url?.includes("/auth/");
79
271
 
80
272
  // Não tenta refresh se:
81
273
  // - Não é 401
82
- // - Já é um retry
274
+ // - Já é um retry de refresh
83
275
  // - É uma rota de auth (login, register, refresh)
84
- // - Não tem refresh token disponível
85
276
  if (!isUnauthorized || isRetry || isAuthRoute) {
86
277
  return handleAxiosError(error);
87
278
  }
88
279
 
89
280
  const refreshToken = client.getRefreshToken();
90
281
  if (!refreshToken) {
91
- // Sem refresh token, limpa sessão e propaga erro
92
282
  client.clearSession();
93
283
  return handleAxiosError(error);
94
284
  }
@@ -110,7 +300,6 @@ export function createHttpClient(client: PlataformaClient): AxiosInstance {
110
300
  isRefreshing = true;
111
301
 
112
302
  try {
113
- // Chama endpoint de refresh diretamente (sem interceptor)
114
303
  const { data } = await axios.post(
115
304
  `${client.apiUrl}/v1/auth/refresh`,
116
305
  { refreshToken },
@@ -120,24 +309,19 @@ export function createHttpClient(client: PlataformaClient): AxiosInstance {
120
309
  const newAccessToken = data.accessToken;
121
310
  const newRefreshToken = data.refreshToken;
122
311
 
123
- // Atualiza tokens no client (persiste no localStorage)
124
312
  client.setToken(newAccessToken);
125
313
  if (newRefreshToken) {
126
314
  client.setRefreshToken(newRefreshToken);
127
315
  }
128
316
 
129
- // Processa fila de requisições pendentes
130
317
  processQueue(null, newAccessToken);
131
318
 
132
- // Refaz a requisição original com novo token
133
319
  originalRequest.headers.Authorization = `Bearer ${newAccessToken}`;
134
320
  return http(originalRequest);
135
321
  } catch (refreshError: any) {
136
- // Refresh falhou - sessão expirada
137
322
  processQueue(refreshError, null);
138
323
  client.clearSession();
139
324
 
140
- // Emite evento de sessão expirada (se houver listener)
141
325
  if (typeof window !== "undefined") {
142
326
  window.dispatchEvent(new CustomEvent("aether:session-expired"));
143
327
  }
package/src/index.ts CHANGED
@@ -6,6 +6,7 @@ import { DatabaseModule } from "./database.js";
6
6
  import { StorageModule } from "./storage.js";
7
7
  import { FunctionsModule } from "./functions.js";
8
8
  import { PushModule } from "./push.js";
9
+ import { TenantAuthModule, TenantUser, TenantLoginResponse, TenantRegisterCredentials, TenantLoginCredentials } from "./tenant-auth.js";
9
10
 
10
11
  // =============================================================================
11
12
  // CONSTANTES DE STORAGE
@@ -51,6 +52,7 @@ export class PlataformaClient {
51
52
  public storage: StorageModule;
52
53
  public functions: FunctionsModule;
53
54
  public push: PushModule;
55
+ public tenantAuth: TenantAuthModule;
54
56
 
55
57
  // Alias para 'db' que o showcase tenta usar como 'database'
56
58
  public database: DatabaseModule;
@@ -90,6 +92,7 @@ export class PlataformaClient {
90
92
  this.storage = new StorageModule(this, this.http);
91
93
  this.functions = new FunctionsModule(this, this.http);
92
94
  this.push = new PushModule(this, this.http);
95
+ this.tenantAuth = new TenantAuthModule(this, this.http);
93
96
 
94
97
  // Cria o alias que o Showcase App espera
95
98
  this.database = this.db;
@@ -241,4 +244,10 @@ export type {
241
244
  PushLogEntry,
242
245
  ListPushLogsOptions,
243
246
  PushStats,
244
- } from "./push.js";
247
+ } from "./push.js";
248
+ export type {
249
+ TenantUser,
250
+ TenantLoginResponse,
251
+ TenantRegisterCredentials,
252
+ TenantLoginCredentials,
253
+ } from "./tenant-auth.js";