@atlas-id/contracts 0.1.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/LICENSE +28 -0
- package/README.md +289 -0
- package/package.json +56 -0
- package/src/contracts-validation.test.ts +136 -0
- package/src/events.test.ts +48 -0
- package/src/events.ts +85 -0
- package/src/index.ts +6 -0
- package/src/notifications.test.ts +80 -0
- package/src/notifications.ts +298 -0
- package/src/oauth-connections.test.ts +80 -0
- package/src/oauth-connections.ts +220 -0
- package/src/schemas/index.ts +138 -0
- package/src/utils/index.ts +1 -0
- package/src/utils/validation.test.ts +96 -0
- package/src/utils/validation.ts +136 -0
- package/src/versioning.test.ts +112 -0
- package/src/versioning.ts +156 -0
|
@@ -0,0 +1,80 @@
|
|
|
1
|
+
import { describe, it, expect } from 'vitest';
|
|
2
|
+
import {
|
|
3
|
+
isEmailNotificationRequest,
|
|
4
|
+
isSmsNotificationRequest,
|
|
5
|
+
isWebhookNotificationRequest,
|
|
6
|
+
notificationEvents,
|
|
7
|
+
type EmailNotificationRequest,
|
|
8
|
+
type SmsNotificationRequest,
|
|
9
|
+
type WebhookNotificationRequest,
|
|
10
|
+
} from './notifications';
|
|
11
|
+
|
|
12
|
+
describe('notifications', () => {
|
|
13
|
+
describe('type guards', () => {
|
|
14
|
+
const emailRequest: EmailNotificationRequest = {
|
|
15
|
+
channel: 'email',
|
|
16
|
+
projectId: 'proj_123',
|
|
17
|
+
to: 'user@example.com',
|
|
18
|
+
templateId: 'tmpl_welcome',
|
|
19
|
+
};
|
|
20
|
+
|
|
21
|
+
const smsRequest: SmsNotificationRequest = {
|
|
22
|
+
channel: 'sms',
|
|
23
|
+
projectId: 'proj_123',
|
|
24
|
+
to: '+1234567890',
|
|
25
|
+
templateId: 'tmpl_sms',
|
|
26
|
+
};
|
|
27
|
+
|
|
28
|
+
const webhookRequest: WebhookNotificationRequest = {
|
|
29
|
+
channel: 'webhook',
|
|
30
|
+
projectId: 'proj_123',
|
|
31
|
+
url: 'https://example.com/webhook',
|
|
32
|
+
signatureVersion: '1.0.0',
|
|
33
|
+
body: { data: 'test' },
|
|
34
|
+
};
|
|
35
|
+
|
|
36
|
+
it('isEmailNotificationRequest debe identificar requests de email', () => {
|
|
37
|
+
expect(isEmailNotificationRequest(emailRequest)).toBe(true);
|
|
38
|
+
expect(isEmailNotificationRequest(smsRequest)).toBe(false);
|
|
39
|
+
expect(isEmailNotificationRequest(webhookRequest)).toBe(false);
|
|
40
|
+
});
|
|
41
|
+
|
|
42
|
+
it('isSmsNotificationRequest debe identificar requests de SMS', () => {
|
|
43
|
+
expect(isSmsNotificationRequest(smsRequest)).toBe(true);
|
|
44
|
+
expect(isSmsNotificationRequest(emailRequest)).toBe(false);
|
|
45
|
+
expect(isSmsNotificationRequest(webhookRequest)).toBe(false);
|
|
46
|
+
});
|
|
47
|
+
|
|
48
|
+
it('isWebhookNotificationRequest debe identificar requests de webhook', () => {
|
|
49
|
+
expect(isWebhookNotificationRequest(webhookRequest)).toBe(true);
|
|
50
|
+
expect(isWebhookNotificationRequest(emailRequest)).toBe(false);
|
|
51
|
+
expect(isWebhookNotificationRequest(smsRequest)).toBe(false);
|
|
52
|
+
});
|
|
53
|
+
});
|
|
54
|
+
|
|
55
|
+
describe('notificationEvents contracts', () => {
|
|
56
|
+
it('debe tener todos los eventos definidos', () => {
|
|
57
|
+
expect(notificationEvents.requested).toBeDefined();
|
|
58
|
+
expect(notificationEvents.dispatched).toBeDefined();
|
|
59
|
+
expect(notificationEvents.delivered).toBeDefined();
|
|
60
|
+
expect(notificationEvents.failed).toBeDefined();
|
|
61
|
+
});
|
|
62
|
+
|
|
63
|
+
it('los ejemplos deben tener la estructura correcta', () => {
|
|
64
|
+
const requestedExample = notificationEvents.requested.example;
|
|
65
|
+
expect(requestedExample.id).toBeDefined();
|
|
66
|
+
expect(requestedExample.type).toBe('notifications.requested');
|
|
67
|
+
expect(requestedExample.version).toBe('1.0.0');
|
|
68
|
+
expect(requestedExample.payload).toBeDefined();
|
|
69
|
+
expect(requestedExample.meta).toBeDefined();
|
|
70
|
+
expect(requestedExample.meta.source).toBeDefined();
|
|
71
|
+
});
|
|
72
|
+
|
|
73
|
+
it('los schemas deben seguir el formato tipo@version', () => {
|
|
74
|
+
expect(notificationEvents.requested.schema).toBe('notifications.requested@1.0.0');
|
|
75
|
+
expect(notificationEvents.dispatched.schema).toBe('notifications.dispatched@1.0.0');
|
|
76
|
+
expect(notificationEvents.delivered.schema).toBe('notifications.delivered@1.0.0');
|
|
77
|
+
expect(notificationEvents.failed.schema).toBe('notifications.failed@1.0.0');
|
|
78
|
+
});
|
|
79
|
+
});
|
|
80
|
+
});
|
|
@@ -0,0 +1,298 @@
|
|
|
1
|
+
import { EventContract, EventEnvelope, NOTIFICATION_EVENT_TYPES } from './events';
|
|
2
|
+
import { SemanticVersion } from './versioning';
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* Canales disponibles para el envío de notificaciones.
|
|
6
|
+
*/
|
|
7
|
+
export type NotificationChannel = 'email' | 'sms' | 'webhook';
|
|
8
|
+
|
|
9
|
+
/**
|
|
10
|
+
* Base común para todas las solicitudes de notificación.
|
|
11
|
+
* Contiene campos compartidos entre todos los tipos de canales.
|
|
12
|
+
*
|
|
13
|
+
* @property channel - Canal de notificación a utilizar
|
|
14
|
+
* @property projectId - ID del proyecto que solicita la notificación
|
|
15
|
+
* @property tenantId - ID del tenant (opcional, para multi-tenancy)
|
|
16
|
+
* @property locale - Código de idioma para localización (ej: 'es-ES', 'en-US')
|
|
17
|
+
* @property deduplicationKey - Clave opcional para prevenir duplicados
|
|
18
|
+
* @property expiresAt - Timestamp ISO 8601 de expiración de la notificación
|
|
19
|
+
* @property metadata - Metadatos adicionales como pares clave-valor
|
|
20
|
+
*/
|
|
21
|
+
export type BaseNotificationRequest = {
|
|
22
|
+
channel: NotificationChannel;
|
|
23
|
+
projectId: string;
|
|
24
|
+
tenantId?: string;
|
|
25
|
+
locale?: string;
|
|
26
|
+
deduplicationKey?: string;
|
|
27
|
+
expiresAt?: string;
|
|
28
|
+
metadata?: Record<string, string>;
|
|
29
|
+
};
|
|
30
|
+
|
|
31
|
+
/**
|
|
32
|
+
* Solicitud de notificación por correo electrónico.
|
|
33
|
+
*
|
|
34
|
+
* @property channel - Debe ser 'email'
|
|
35
|
+
* @property to - Dirección de correo electrónico del destinatario
|
|
36
|
+
* @property templateId - ID del template de email a utilizar
|
|
37
|
+
* @property variables - Variables para reemplazar en el template
|
|
38
|
+
* @property cc - Lista opcional de direcciones en copia
|
|
39
|
+
* @property bcc - Lista opcional de direcciones en copia oculta
|
|
40
|
+
* @property replyTo - Dirección de respuesta opcional
|
|
41
|
+
*/
|
|
42
|
+
export type EmailNotificationRequest = BaseNotificationRequest & {
|
|
43
|
+
channel: 'email';
|
|
44
|
+
to: string;
|
|
45
|
+
templateId: string;
|
|
46
|
+
variables?: Record<string, string | number | boolean | null>;
|
|
47
|
+
cc?: string[];
|
|
48
|
+
bcc?: string[];
|
|
49
|
+
replyTo?: string;
|
|
50
|
+
};
|
|
51
|
+
|
|
52
|
+
/**
|
|
53
|
+
* Solicitud de notificación por SMS.
|
|
54
|
+
*
|
|
55
|
+
* @property channel - Debe ser 'sms'
|
|
56
|
+
* @property to - Número de teléfono del destinatario (formato E.164 recomendado)
|
|
57
|
+
* @property templateId - ID del template de SMS a utilizar
|
|
58
|
+
* @property variables - Variables para reemplazar en el template
|
|
59
|
+
*/
|
|
60
|
+
export type SmsNotificationRequest = BaseNotificationRequest & {
|
|
61
|
+
channel: 'sms';
|
|
62
|
+
to: string;
|
|
63
|
+
templateId: string;
|
|
64
|
+
variables?: Record<string, string | number | boolean | null>;
|
|
65
|
+
};
|
|
66
|
+
|
|
67
|
+
/**
|
|
68
|
+
* Solicitud de notificación por webhook.
|
|
69
|
+
*
|
|
70
|
+
* @property channel - Debe ser 'webhook'
|
|
71
|
+
* @property url - URL del endpoint que recibirá el webhook
|
|
72
|
+
* @property signatureVersion - Versión del algoritmo de firma para verificación
|
|
73
|
+
* @property body - Cuerpo del webhook (estructura libre)
|
|
74
|
+
*/
|
|
75
|
+
export type WebhookNotificationRequest = BaseNotificationRequest & {
|
|
76
|
+
channel: 'webhook';
|
|
77
|
+
url: string;
|
|
78
|
+
signatureVersion: SemanticVersion;
|
|
79
|
+
body: unknown;
|
|
80
|
+
};
|
|
81
|
+
|
|
82
|
+
/**
|
|
83
|
+
* Unión de todos los tipos de solicitudes de notificación.
|
|
84
|
+
* Utiliza discriminated union basado en el campo 'channel'.
|
|
85
|
+
*/
|
|
86
|
+
export type NotificationRequest =
|
|
87
|
+
| EmailNotificationRequest
|
|
88
|
+
| SmsNotificationRequest
|
|
89
|
+
| WebhookNotificationRequest;
|
|
90
|
+
|
|
91
|
+
/**
|
|
92
|
+
* Estados posibles de una notificación en su ciclo de vida.
|
|
93
|
+
*
|
|
94
|
+
* - requested: Solicitud creada, pendiente de procesamiento
|
|
95
|
+
* - scheduled: Programada para envío futuro
|
|
96
|
+
* - dispatched: Enviada al proveedor externo
|
|
97
|
+
* - delivered: Confirmada como entregada por el proveedor
|
|
98
|
+
* - failed: Falló el envío o entrega
|
|
99
|
+
* - cancelled: Cancelada antes de ser enviada
|
|
100
|
+
*/
|
|
101
|
+
export type NotificationStatus =
|
|
102
|
+
| 'requested'
|
|
103
|
+
| 'scheduled'
|
|
104
|
+
| 'dispatched'
|
|
105
|
+
| 'delivered'
|
|
106
|
+
| 'failed'
|
|
107
|
+
| 'cancelled';
|
|
108
|
+
|
|
109
|
+
/**
|
|
110
|
+
* Envelope que contiene una notificación con su estado y metadata.
|
|
111
|
+
*
|
|
112
|
+
* @property id - Identificador único de la notificación
|
|
113
|
+
* @property request - Solicitud original de la notificación
|
|
114
|
+
* @property status - Estado actual de la notificación
|
|
115
|
+
* @property createdAt - Timestamp ISO 8601 de creación
|
|
116
|
+
* @property updatedAt - Timestamp ISO 8601 de última actualización
|
|
117
|
+
* @property lastError - Información del último error si el estado es 'failed'
|
|
118
|
+
* @property providerResponseId - ID de respuesta del proveedor externo
|
|
119
|
+
*/
|
|
120
|
+
export type NotificationEnvelope = {
|
|
121
|
+
id: string;
|
|
122
|
+
request: NotificationRequest;
|
|
123
|
+
status: NotificationStatus;
|
|
124
|
+
createdAt: string;
|
|
125
|
+
updatedAt: string;
|
|
126
|
+
lastError?: {
|
|
127
|
+
code: string;
|
|
128
|
+
message: string;
|
|
129
|
+
retriable: boolean;
|
|
130
|
+
};
|
|
131
|
+
providerResponseId?: string;
|
|
132
|
+
};
|
|
133
|
+
|
|
134
|
+
/**
|
|
135
|
+
* Type guard para verificar si una solicitud es de tipo email.
|
|
136
|
+
*
|
|
137
|
+
* @param request - Solicitud a verificar
|
|
138
|
+
* @returns true si es EmailNotificationRequest
|
|
139
|
+
*/
|
|
140
|
+
export function isEmailNotificationRequest(
|
|
141
|
+
request: NotificationRequest
|
|
142
|
+
): request is EmailNotificationRequest {
|
|
143
|
+
return request.channel === 'email';
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
/**
|
|
147
|
+
* Type guard para verificar si una solicitud es de tipo SMS.
|
|
148
|
+
*
|
|
149
|
+
* @param request - Solicitud a verificar
|
|
150
|
+
* @returns true si es SmsNotificationRequest
|
|
151
|
+
*/
|
|
152
|
+
export function isSmsNotificationRequest(
|
|
153
|
+
request: NotificationRequest
|
|
154
|
+
): request is SmsNotificationRequest {
|
|
155
|
+
return request.channel === 'sms';
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
/**
|
|
159
|
+
* Type guard para verificar si una solicitud es de tipo webhook.
|
|
160
|
+
*
|
|
161
|
+
* @param request - Solicitud a verificar
|
|
162
|
+
* @returns true si es WebhookNotificationRequest
|
|
163
|
+
*/
|
|
164
|
+
export function isWebhookNotificationRequest(
|
|
165
|
+
request: NotificationRequest
|
|
166
|
+
): request is WebhookNotificationRequest {
|
|
167
|
+
return request.channel === 'webhook';
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
type RequestedPayload = {
|
|
171
|
+
notification: NotificationEnvelope;
|
|
172
|
+
};
|
|
173
|
+
|
|
174
|
+
type DispatchedPayload = {
|
|
175
|
+
notificationId: string;
|
|
176
|
+
channel: NotificationChannel;
|
|
177
|
+
provider: string;
|
|
178
|
+
providerMessageId?: string;
|
|
179
|
+
};
|
|
180
|
+
|
|
181
|
+
type DeliveredPayload = {
|
|
182
|
+
notificationId: string;
|
|
183
|
+
deliveredAt: string;
|
|
184
|
+
};
|
|
185
|
+
|
|
186
|
+
type FailedPayload = {
|
|
187
|
+
notificationId: string;
|
|
188
|
+
failedAt: string;
|
|
189
|
+
errorCode: string;
|
|
190
|
+
message: string;
|
|
191
|
+
retriable: boolean;
|
|
192
|
+
};
|
|
193
|
+
|
|
194
|
+
/**
|
|
195
|
+
* Contratos de eventos para el sistema de notificaciones.
|
|
196
|
+
* Define los eventos que se emiten durante el ciclo de vida de una notificación.
|
|
197
|
+
*/
|
|
198
|
+
export const notificationEvents: Record<
|
|
199
|
+
'requested' | 'dispatched' | 'delivered' | 'failed',
|
|
200
|
+
EventContract<RequestedPayload | DispatchedPayload | DeliveredPayload | FailedPayload>
|
|
201
|
+
> = {
|
|
202
|
+
requested: {
|
|
203
|
+
type: NOTIFICATION_EVENT_TYPES.REQUESTED,
|
|
204
|
+
version: '1.0.0',
|
|
205
|
+
schema: 'notifications.requested@1.0.0',
|
|
206
|
+
example: {
|
|
207
|
+
id: 'evt_123',
|
|
208
|
+
type: NOTIFICATION_EVENT_TYPES.REQUESTED,
|
|
209
|
+
version: '1.0.0',
|
|
210
|
+
occurredAt: new Date().toISOString(),
|
|
211
|
+
payload: {
|
|
212
|
+
notification: {
|
|
213
|
+
id: 'ntf_123',
|
|
214
|
+
request: {
|
|
215
|
+
channel: 'email',
|
|
216
|
+
projectId: 'proj_123',
|
|
217
|
+
to: 'user@example.com',
|
|
218
|
+
templateId: 'tmpl_welcome',
|
|
219
|
+
},
|
|
220
|
+
status: 'requested',
|
|
221
|
+
createdAt: new Date().toISOString(),
|
|
222
|
+
updatedAt: new Date().toISOString(),
|
|
223
|
+
},
|
|
224
|
+
},
|
|
225
|
+
meta: {
|
|
226
|
+
source: 'notifications-service',
|
|
227
|
+
},
|
|
228
|
+
},
|
|
229
|
+
},
|
|
230
|
+
dispatched: {
|
|
231
|
+
type: NOTIFICATION_EVENT_TYPES.DISPATCHED,
|
|
232
|
+
version: '1.0.0',
|
|
233
|
+
schema: 'notifications.dispatched@1.0.0',
|
|
234
|
+
example: {
|
|
235
|
+
id: 'evt_124',
|
|
236
|
+
type: NOTIFICATION_EVENT_TYPES.DISPATCHED,
|
|
237
|
+
version: '1.0.0',
|
|
238
|
+
occurredAt: new Date().toISOString(),
|
|
239
|
+
payload: {
|
|
240
|
+
notificationId: 'ntf_123',
|
|
241
|
+
channel: 'email',
|
|
242
|
+
provider: 'postmark',
|
|
243
|
+
providerMessageId: 'msg_abc',
|
|
244
|
+
},
|
|
245
|
+
meta: {
|
|
246
|
+
source: 'notifications-service',
|
|
247
|
+
},
|
|
248
|
+
},
|
|
249
|
+
},
|
|
250
|
+
delivered: {
|
|
251
|
+
type: NOTIFICATION_EVENT_TYPES.DELIVERED,
|
|
252
|
+
version: '1.0.0',
|
|
253
|
+
schema: 'notifications.delivered@1.0.0',
|
|
254
|
+
example: {
|
|
255
|
+
id: 'evt_125',
|
|
256
|
+
type: NOTIFICATION_EVENT_TYPES.DELIVERED,
|
|
257
|
+
version: '1.0.0',
|
|
258
|
+
occurredAt: new Date().toISOString(),
|
|
259
|
+
payload: {
|
|
260
|
+
notificationId: 'ntf_123',
|
|
261
|
+
deliveredAt: new Date().toISOString(),
|
|
262
|
+
},
|
|
263
|
+
meta: {
|
|
264
|
+
source: 'notifications-service',
|
|
265
|
+
},
|
|
266
|
+
},
|
|
267
|
+
},
|
|
268
|
+
failed: {
|
|
269
|
+
type: NOTIFICATION_EVENT_TYPES.FAILED,
|
|
270
|
+
version: '1.0.0',
|
|
271
|
+
schema: 'notifications.failed@1.0.0',
|
|
272
|
+
example: {
|
|
273
|
+
id: 'evt_126',
|
|
274
|
+
type: NOTIFICATION_EVENT_TYPES.FAILED,
|
|
275
|
+
version: '1.0.0',
|
|
276
|
+
occurredAt: new Date().toISOString(),
|
|
277
|
+
payload: {
|
|
278
|
+
notificationId: 'ntf_123',
|
|
279
|
+
failedAt: new Date().toISOString(),
|
|
280
|
+
errorCode: 'bounce',
|
|
281
|
+
message: 'Mailbox unreachable',
|
|
282
|
+
retriable: false,
|
|
283
|
+
},
|
|
284
|
+
meta: {
|
|
285
|
+
source: 'notifications-service',
|
|
286
|
+
},
|
|
287
|
+
},
|
|
288
|
+
},
|
|
289
|
+
};
|
|
290
|
+
|
|
291
|
+
/**
|
|
292
|
+
* Unión de todos los tipos de eventos de notificaciones.
|
|
293
|
+
*/
|
|
294
|
+
export type NotificationEvent =
|
|
295
|
+
| EventEnvelope<RequestedPayload>
|
|
296
|
+
| EventEnvelope<DispatchedPayload>
|
|
297
|
+
| EventEnvelope<DeliveredPayload>
|
|
298
|
+
| EventEnvelope<FailedPayload>;
|
|
@@ -0,0 +1,80 @@
|
|
|
1
|
+
import { describe, it, expect } from 'vitest';
|
|
2
|
+
import {
|
|
3
|
+
isActiveConnection,
|
|
4
|
+
isRevokedConnection,
|
|
5
|
+
isExpiredConnection,
|
|
6
|
+
oauthConnectionEvents,
|
|
7
|
+
type OAuthConnection,
|
|
8
|
+
} from './oauth-connections';
|
|
9
|
+
|
|
10
|
+
describe('oauth-connections', () => {
|
|
11
|
+
describe('type guards', () => {
|
|
12
|
+
const activeConnection: OAuthConnection = {
|
|
13
|
+
id: 'conn_1',
|
|
14
|
+
providerId: 'google',
|
|
15
|
+
projectId: 'proj_123',
|
|
16
|
+
userId: 'user_123',
|
|
17
|
+
scope: ['calendar'],
|
|
18
|
+
status: 'active',
|
|
19
|
+
createdAt: new Date().toISOString(),
|
|
20
|
+
updatedAt: new Date().toISOString(),
|
|
21
|
+
};
|
|
22
|
+
|
|
23
|
+
const revokedConnection: OAuthConnection = {
|
|
24
|
+
...activeConnection,
|
|
25
|
+
id: 'conn_2',
|
|
26
|
+
status: 'revoked',
|
|
27
|
+
};
|
|
28
|
+
|
|
29
|
+
const expiredConnection: OAuthConnection = {
|
|
30
|
+
...activeConnection,
|
|
31
|
+
id: 'conn_3',
|
|
32
|
+
status: 'expired',
|
|
33
|
+
expiresAt: new Date(Date.now() - 1000).toISOString(),
|
|
34
|
+
};
|
|
35
|
+
|
|
36
|
+
it('isActiveConnection debe identificar conexiones activas', () => {
|
|
37
|
+
expect(isActiveConnection(activeConnection)).toBe(true);
|
|
38
|
+
expect(isActiveConnection(revokedConnection)).toBe(false);
|
|
39
|
+
});
|
|
40
|
+
|
|
41
|
+
it('isRevokedConnection debe identificar conexiones revocadas', () => {
|
|
42
|
+
expect(isRevokedConnection(revokedConnection)).toBe(true);
|
|
43
|
+
expect(isRevokedConnection(activeConnection)).toBe(false);
|
|
44
|
+
});
|
|
45
|
+
|
|
46
|
+
it('isExpiredConnection debe identificar conexiones expiradas', () => {
|
|
47
|
+
expect(isExpiredConnection(expiredConnection)).toBe(true);
|
|
48
|
+
expect(isExpiredConnection(activeConnection)).toBe(false);
|
|
49
|
+
|
|
50
|
+
const connectionWithPastExpiry: OAuthConnection = {
|
|
51
|
+
...activeConnection,
|
|
52
|
+
expiresAt: new Date(Date.now() - 1000).toISOString(),
|
|
53
|
+
};
|
|
54
|
+
expect(isExpiredConnection(connectionWithPastExpiry)).toBe(true);
|
|
55
|
+
});
|
|
56
|
+
});
|
|
57
|
+
|
|
58
|
+
describe('oauthConnectionEvents contracts', () => {
|
|
59
|
+
it('debe tener todos los eventos definidos', () => {
|
|
60
|
+
expect(oauthConnectionEvents.linked).toBeDefined();
|
|
61
|
+
expect(oauthConnectionEvents.tokenRefreshed).toBeDefined();
|
|
62
|
+
expect(oauthConnectionEvents.revoked).toBeDefined();
|
|
63
|
+
});
|
|
64
|
+
|
|
65
|
+
it('los ejemplos deben tener la estructura correcta', () => {
|
|
66
|
+
const linkedExample = oauthConnectionEvents.linked.example;
|
|
67
|
+
expect(linkedExample.id).toBeDefined();
|
|
68
|
+
expect(linkedExample.type).toBe('oauth.connection.linked');
|
|
69
|
+
expect(linkedExample.version).toBe('1.0.0');
|
|
70
|
+
expect(linkedExample.payload).toBeDefined();
|
|
71
|
+
expect(linkedExample.meta).toBeDefined();
|
|
72
|
+
});
|
|
73
|
+
|
|
74
|
+
it('los schemas deben seguir el formato tipo@version', () => {
|
|
75
|
+
expect(oauthConnectionEvents.linked.schema).toBe('oauth.connection.linked@1.0.0');
|
|
76
|
+
expect(oauthConnectionEvents.tokenRefreshed.schema).toBe('oauth.connection.token_refreshed@1.0.0');
|
|
77
|
+
expect(oauthConnectionEvents.revoked.schema).toBe('oauth.connection.revoked@1.0.0');
|
|
78
|
+
});
|
|
79
|
+
});
|
|
80
|
+
});
|
|
@@ -0,0 +1,220 @@
|
|
|
1
|
+
import { EventContract, EventEnvelope, OAUTH_CONNECTION_EVENT_TYPES } from './events';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Categorías de proveedores OAuth soportados.
|
|
5
|
+
*
|
|
6
|
+
* - oauth2: OAuth 2.0 estándar
|
|
7
|
+
* - oidc: OpenID Connect (extensión de OAuth 2.0)
|
|
8
|
+
* - saml: Security Assertion Markup Language
|
|
9
|
+
*/
|
|
10
|
+
export type OAuthProviderCategory = 'oauth2' | 'oidc' | 'saml';
|
|
11
|
+
|
|
12
|
+
/**
|
|
13
|
+
* Definición de un proveedor OAuth configurado en el sistema.
|
|
14
|
+
*
|
|
15
|
+
* @property id - Identificador único del proveedor (ej: 'google', 'github')
|
|
16
|
+
* @property name - Nombre legible del proveedor
|
|
17
|
+
* @property category - Categoría del protocolo OAuth
|
|
18
|
+
* @property authorizationUrl - URL del endpoint de autorización
|
|
19
|
+
* @property tokenUrl - URL del endpoint de intercambio de tokens
|
|
20
|
+
* @property scopes - Lista de scopes soportados por el proveedor
|
|
21
|
+
* @property supportsPkce - Indica si el proveedor soporta PKCE (Proof Key for Code Exchange)
|
|
22
|
+
* @property requiresWebhookForRefresh - Indica si requiere webhook para refrescar tokens
|
|
23
|
+
*/
|
|
24
|
+
export type OAuthProvider = {
|
|
25
|
+
id: string;
|
|
26
|
+
name: string;
|
|
27
|
+
category: OAuthProviderCategory;
|
|
28
|
+
authorizationUrl: string;
|
|
29
|
+
tokenUrl: string;
|
|
30
|
+
scopes: string[];
|
|
31
|
+
supportsPkce: boolean;
|
|
32
|
+
requiresWebhookForRefresh?: boolean;
|
|
33
|
+
};
|
|
34
|
+
|
|
35
|
+
/**
|
|
36
|
+
* Conexión OAuth establecida entre un usuario y un proveedor.
|
|
37
|
+
* Representa la autorización activa de un usuario para acceder a recursos del proveedor.
|
|
38
|
+
*
|
|
39
|
+
* @property id - Identificador único de la conexión
|
|
40
|
+
* @property providerId - ID del proveedor OAuth
|
|
41
|
+
* @property projectId - ID del proyecto asociado
|
|
42
|
+
* @property tenantId - ID del tenant (opcional, para multi-tenancy)
|
|
43
|
+
* @property userId - ID del usuario que autorizó la conexión
|
|
44
|
+
* @property scope - Lista de scopes autorizados en esta conexión
|
|
45
|
+
* @property expiresAt - Timestamp ISO 8601 de expiración de la conexión
|
|
46
|
+
* @property createdAt - Timestamp ISO 8601 de creación
|
|
47
|
+
* @property updatedAt - Timestamp ISO 8601 de última actualización
|
|
48
|
+
* @property status - Estado actual de la conexión
|
|
49
|
+
*/
|
|
50
|
+
export type OAuthConnection = {
|
|
51
|
+
id: string;
|
|
52
|
+
providerId: string;
|
|
53
|
+
projectId: string;
|
|
54
|
+
tenantId?: string;
|
|
55
|
+
userId: string;
|
|
56
|
+
scope: string[];
|
|
57
|
+
expiresAt?: string;
|
|
58
|
+
createdAt: string;
|
|
59
|
+
updatedAt: string;
|
|
60
|
+
status: 'active' | 'refreshing' | 'revoked' | 'expired';
|
|
61
|
+
};
|
|
62
|
+
|
|
63
|
+
/**
|
|
64
|
+
* Conjunto de tokens OAuth obtenidos de un proveedor.
|
|
65
|
+
*
|
|
66
|
+
* @property accessToken - Token de acceso para autenticación
|
|
67
|
+
* @property refreshToken - Token para refrescar el access token (opcional)
|
|
68
|
+
* @property expiresIn - Tiempo de expiración en segundos (opcional)
|
|
69
|
+
* @property tokenType - Tipo de token (generalmente 'Bearer')
|
|
70
|
+
* @property issuedAt - Timestamp ISO 8601 de emisión
|
|
71
|
+
* @property expiresAt - Timestamp ISO 8601 de expiración calculado
|
|
72
|
+
* @property idToken - ID Token para OIDC (opcional)
|
|
73
|
+
*/
|
|
74
|
+
export type TokenSet = {
|
|
75
|
+
accessToken: string;
|
|
76
|
+
refreshToken?: string;
|
|
77
|
+
expiresIn?: number;
|
|
78
|
+
tokenType?: string;
|
|
79
|
+
issuedAt: string;
|
|
80
|
+
expiresAt?: string;
|
|
81
|
+
idToken?: string;
|
|
82
|
+
};
|
|
83
|
+
|
|
84
|
+
/**
|
|
85
|
+
* Type guard para verificar si una conexión está activa.
|
|
86
|
+
*
|
|
87
|
+
* @param connection - Conexión a verificar
|
|
88
|
+
* @returns true si el estado es 'active'
|
|
89
|
+
*/
|
|
90
|
+
export function isActiveConnection(connection: OAuthConnection): boolean {
|
|
91
|
+
return connection.status === 'active';
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
/**
|
|
95
|
+
* Type guard para verificar si una conexión está revocada.
|
|
96
|
+
*
|
|
97
|
+
* @param connection - Conexión a verificar
|
|
98
|
+
* @returns true si el estado es 'revoked'
|
|
99
|
+
*/
|
|
100
|
+
export function isRevokedConnection(connection: OAuthConnection): boolean {
|
|
101
|
+
return connection.status === 'revoked';
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
/**
|
|
105
|
+
* Type guard para verificar si una conexión está expirada.
|
|
106
|
+
*
|
|
107
|
+
* @param connection - Conexión a verificar
|
|
108
|
+
* @returns true si el estado es 'expired' o si expiresAt está en el pasado
|
|
109
|
+
*/
|
|
110
|
+
export function isExpiredConnection(connection: OAuthConnection): boolean {
|
|
111
|
+
if (connection.status === 'expired') return true;
|
|
112
|
+
if (!connection.expiresAt) return false;
|
|
113
|
+
return new Date(connection.expiresAt) < new Date();
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
type ConnectionLinkedPayload = {
|
|
117
|
+
connection: OAuthConnection;
|
|
118
|
+
tokenSet: TokenSet;
|
|
119
|
+
};
|
|
120
|
+
|
|
121
|
+
type TokenRefreshedPayload = {
|
|
122
|
+
connectionId: string;
|
|
123
|
+
tokenSet: TokenSet;
|
|
124
|
+
};
|
|
125
|
+
|
|
126
|
+
type ConnectionRevokedPayload = {
|
|
127
|
+
connectionId: string;
|
|
128
|
+
reason: 'user' | 'provider' | 'security';
|
|
129
|
+
};
|
|
130
|
+
|
|
131
|
+
/**
|
|
132
|
+
* Contratos de eventos para el sistema de conexiones OAuth.
|
|
133
|
+
* Define los eventos que se emiten durante el ciclo de vida de una conexión OAuth.
|
|
134
|
+
*/
|
|
135
|
+
export const oauthConnectionEvents: Record<
|
|
136
|
+
'linked' | 'tokenRefreshed' | 'revoked',
|
|
137
|
+
EventContract<ConnectionLinkedPayload | TokenRefreshedPayload | ConnectionRevokedPayload>
|
|
138
|
+
> = {
|
|
139
|
+
linked: {
|
|
140
|
+
type: OAUTH_CONNECTION_EVENT_TYPES.LINKED,
|
|
141
|
+
version: '1.0.0',
|
|
142
|
+
schema: 'oauth.connection.linked@1.0.0',
|
|
143
|
+
example: {
|
|
144
|
+
id: 'evt_conn_1',
|
|
145
|
+
type: OAUTH_CONNECTION_EVENT_TYPES.LINKED,
|
|
146
|
+
version: '1.0.0',
|
|
147
|
+
occurredAt: new Date().toISOString(),
|
|
148
|
+
payload: {
|
|
149
|
+
connection: {
|
|
150
|
+
id: 'conn_123',
|
|
151
|
+
providerId: 'google',
|
|
152
|
+
projectId: 'proj_123',
|
|
153
|
+
userId: 'user_123',
|
|
154
|
+
scope: ['calendar', 'email'],
|
|
155
|
+
status: 'active',
|
|
156
|
+
createdAt: new Date().toISOString(),
|
|
157
|
+
updatedAt: new Date().toISOString(),
|
|
158
|
+
},
|
|
159
|
+
tokenSet: {
|
|
160
|
+
accessToken: 'access-token',
|
|
161
|
+
refreshToken: 'refresh-token',
|
|
162
|
+
issuedAt: new Date().toISOString(),
|
|
163
|
+
expiresIn: 3600,
|
|
164
|
+
},
|
|
165
|
+
},
|
|
166
|
+
meta: {
|
|
167
|
+
source: 'oauth-connections-service',
|
|
168
|
+
},
|
|
169
|
+
},
|
|
170
|
+
},
|
|
171
|
+
tokenRefreshed: {
|
|
172
|
+
type: OAUTH_CONNECTION_EVENT_TYPES.TOKEN_REFRESHED,
|
|
173
|
+
version: '1.0.0',
|
|
174
|
+
schema: 'oauth.connection.token_refreshed@1.0.0',
|
|
175
|
+
example: {
|
|
176
|
+
id: 'evt_conn_2',
|
|
177
|
+
type: OAUTH_CONNECTION_EVENT_TYPES.TOKEN_REFRESHED,
|
|
178
|
+
version: '1.0.0',
|
|
179
|
+
occurredAt: new Date().toISOString(),
|
|
180
|
+
payload: {
|
|
181
|
+
connectionId: 'conn_123',
|
|
182
|
+
tokenSet: {
|
|
183
|
+
accessToken: 'access-token-2',
|
|
184
|
+
refreshToken: 'refresh-token',
|
|
185
|
+
issuedAt: new Date().toISOString(),
|
|
186
|
+
expiresIn: 3600,
|
|
187
|
+
},
|
|
188
|
+
},
|
|
189
|
+
meta: {
|
|
190
|
+
source: 'oauth-connections-service',
|
|
191
|
+
},
|
|
192
|
+
},
|
|
193
|
+
},
|
|
194
|
+
revoked: {
|
|
195
|
+
type: OAUTH_CONNECTION_EVENT_TYPES.REVOKED,
|
|
196
|
+
version: '1.0.0',
|
|
197
|
+
schema: 'oauth.connection.revoked@1.0.0',
|
|
198
|
+
example: {
|
|
199
|
+
id: 'evt_conn_3',
|
|
200
|
+
type: OAUTH_CONNECTION_EVENT_TYPES.REVOKED,
|
|
201
|
+
version: '1.0.0',
|
|
202
|
+
occurredAt: new Date().toISOString(),
|
|
203
|
+
payload: {
|
|
204
|
+
connectionId: 'conn_123',
|
|
205
|
+
reason: 'security',
|
|
206
|
+
},
|
|
207
|
+
meta: {
|
|
208
|
+
source: 'oauth-connections-service',
|
|
209
|
+
},
|
|
210
|
+
},
|
|
211
|
+
},
|
|
212
|
+
};
|
|
213
|
+
|
|
214
|
+
/**
|
|
215
|
+
* Unión de todos los tipos de eventos de conexiones OAuth.
|
|
216
|
+
*/
|
|
217
|
+
export type OAuthConnectionEvent =
|
|
218
|
+
| EventEnvelope<ConnectionLinkedPayload>
|
|
219
|
+
| EventEnvelope<TokenRefreshedPayload>
|
|
220
|
+
| EventEnvelope<ConnectionRevokedPayload>;
|