@enderworld/onlyapi 1.5.1

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.
Files changed (160) hide show
  1. package/CHANGELOG.md +201 -0
  2. package/LICENSE +21 -0
  3. package/README.md +338 -0
  4. package/dist/cli.js +14 -0
  5. package/package.json +69 -0
  6. package/src/application/dtos/admin.dto.ts +25 -0
  7. package/src/application/dtos/auth.dto.ts +97 -0
  8. package/src/application/dtos/index.ts +40 -0
  9. package/src/application/index.ts +2 -0
  10. package/src/application/services/admin.service.ts +150 -0
  11. package/src/application/services/api-key.service.ts +65 -0
  12. package/src/application/services/auth.service.ts +606 -0
  13. package/src/application/services/health.service.ts +97 -0
  14. package/src/application/services/index.ts +10 -0
  15. package/src/application/services/user.service.ts +95 -0
  16. package/src/cli/commands/help.ts +86 -0
  17. package/src/cli/commands/init.ts +301 -0
  18. package/src/cli/commands/upgrade.ts +471 -0
  19. package/src/cli/index.ts +76 -0
  20. package/src/cli/ui.ts +189 -0
  21. package/src/cluster.ts +62 -0
  22. package/src/core/entities/index.ts +1 -0
  23. package/src/core/entities/user.entity.ts +24 -0
  24. package/src/core/errors/app-error.ts +81 -0
  25. package/src/core/errors/index.ts +15 -0
  26. package/src/core/index.ts +7 -0
  27. package/src/core/ports/account-lockout.ts +15 -0
  28. package/src/core/ports/alert-sink.ts +27 -0
  29. package/src/core/ports/api-key.ts +37 -0
  30. package/src/core/ports/audit-log.ts +46 -0
  31. package/src/core/ports/cache.ts +24 -0
  32. package/src/core/ports/circuit-breaker.ts +42 -0
  33. package/src/core/ports/event-bus.ts +78 -0
  34. package/src/core/ports/index.ts +62 -0
  35. package/src/core/ports/job-queue.ts +73 -0
  36. package/src/core/ports/logger.ts +21 -0
  37. package/src/core/ports/metrics.ts +49 -0
  38. package/src/core/ports/oauth.ts +55 -0
  39. package/src/core/ports/password-hasher.ts +10 -0
  40. package/src/core/ports/password-history.ts +23 -0
  41. package/src/core/ports/password-policy.ts +43 -0
  42. package/src/core/ports/refresh-token-store.ts +37 -0
  43. package/src/core/ports/retry.ts +23 -0
  44. package/src/core/ports/token-blacklist.ts +16 -0
  45. package/src/core/ports/token-service.ts +23 -0
  46. package/src/core/ports/totp-service.ts +16 -0
  47. package/src/core/ports/user.repository.ts +40 -0
  48. package/src/core/ports/verification-token.ts +41 -0
  49. package/src/core/ports/webhook.ts +58 -0
  50. package/src/core/types/brand.ts +19 -0
  51. package/src/core/types/index.ts +19 -0
  52. package/src/core/types/pagination.ts +28 -0
  53. package/src/core/types/result.ts +52 -0
  54. package/src/infrastructure/alerting/index.ts +1 -0
  55. package/src/infrastructure/alerting/webhook.ts +100 -0
  56. package/src/infrastructure/cache/in-memory-cache.ts +111 -0
  57. package/src/infrastructure/cache/index.ts +6 -0
  58. package/src/infrastructure/cache/redis-cache.ts +204 -0
  59. package/src/infrastructure/config/config.ts +185 -0
  60. package/src/infrastructure/config/index.ts +1 -0
  61. package/src/infrastructure/database/in-memory-user.repository.ts +134 -0
  62. package/src/infrastructure/database/index.ts +37 -0
  63. package/src/infrastructure/database/migrations/001_create_users.ts +26 -0
  64. package/src/infrastructure/database/migrations/002_create_token_blacklist.ts +21 -0
  65. package/src/infrastructure/database/migrations/003_create_audit_log.ts +31 -0
  66. package/src/infrastructure/database/migrations/004_auth_platform.ts +112 -0
  67. package/src/infrastructure/database/migrations/runner.ts +120 -0
  68. package/src/infrastructure/database/mssql/index.ts +14 -0
  69. package/src/infrastructure/database/mssql/migrations.ts +299 -0
  70. package/src/infrastructure/database/mssql/mssql-account-lockout.ts +95 -0
  71. package/src/infrastructure/database/mssql/mssql-api-keys.ts +146 -0
  72. package/src/infrastructure/database/mssql/mssql-audit-log.ts +86 -0
  73. package/src/infrastructure/database/mssql/mssql-oauth-accounts.ts +118 -0
  74. package/src/infrastructure/database/mssql/mssql-password-history.ts +71 -0
  75. package/src/infrastructure/database/mssql/mssql-refresh-token-store.ts +144 -0
  76. package/src/infrastructure/database/mssql/mssql-token-blacklist.ts +54 -0
  77. package/src/infrastructure/database/mssql/mssql-user.repository.ts +263 -0
  78. package/src/infrastructure/database/mssql/mssql-verification-tokens.ts +120 -0
  79. package/src/infrastructure/database/postgres/index.ts +14 -0
  80. package/src/infrastructure/database/postgres/migrations.ts +235 -0
  81. package/src/infrastructure/database/postgres/pg-account-lockout.ts +75 -0
  82. package/src/infrastructure/database/postgres/pg-api-keys.ts +126 -0
  83. package/src/infrastructure/database/postgres/pg-audit-log.ts +74 -0
  84. package/src/infrastructure/database/postgres/pg-oauth-accounts.ts +101 -0
  85. package/src/infrastructure/database/postgres/pg-password-history.ts +61 -0
  86. package/src/infrastructure/database/postgres/pg-refresh-token-store.ts +117 -0
  87. package/src/infrastructure/database/postgres/pg-token-blacklist.ts +48 -0
  88. package/src/infrastructure/database/postgres/pg-user.repository.ts +237 -0
  89. package/src/infrastructure/database/postgres/pg-verification-tokens.ts +97 -0
  90. package/src/infrastructure/database/sqlite-account-lockout.ts +97 -0
  91. package/src/infrastructure/database/sqlite-api-keys.ts +155 -0
  92. package/src/infrastructure/database/sqlite-audit-log.ts +90 -0
  93. package/src/infrastructure/database/sqlite-oauth-accounts.ts +105 -0
  94. package/src/infrastructure/database/sqlite-password-history.ts +54 -0
  95. package/src/infrastructure/database/sqlite-refresh-token-store.ts +122 -0
  96. package/src/infrastructure/database/sqlite-token-blacklist.ts +47 -0
  97. package/src/infrastructure/database/sqlite-user.repository.ts +260 -0
  98. package/src/infrastructure/database/sqlite-verification-tokens.ts +112 -0
  99. package/src/infrastructure/events/event-bus.ts +105 -0
  100. package/src/infrastructure/events/event-factory.ts +31 -0
  101. package/src/infrastructure/events/in-memory-webhook-registry.ts +67 -0
  102. package/src/infrastructure/events/index.ts +4 -0
  103. package/src/infrastructure/events/webhook-dispatcher.ts +114 -0
  104. package/src/infrastructure/index.ts +58 -0
  105. package/src/infrastructure/jobs/index.ts +1 -0
  106. package/src/infrastructure/jobs/job-queue.ts +185 -0
  107. package/src/infrastructure/logging/index.ts +1 -0
  108. package/src/infrastructure/logging/logger.ts +63 -0
  109. package/src/infrastructure/metrics/index.ts +1 -0
  110. package/src/infrastructure/metrics/prometheus.ts +231 -0
  111. package/src/infrastructure/oauth/github.ts +116 -0
  112. package/src/infrastructure/oauth/google.ts +83 -0
  113. package/src/infrastructure/oauth/index.ts +2 -0
  114. package/src/infrastructure/resilience/circuit-breaker.ts +133 -0
  115. package/src/infrastructure/resilience/index.ts +2 -0
  116. package/src/infrastructure/resilience/retry.ts +50 -0
  117. package/src/infrastructure/security/account-lockout.ts +73 -0
  118. package/src/infrastructure/security/index.ts +6 -0
  119. package/src/infrastructure/security/password-hasher.ts +31 -0
  120. package/src/infrastructure/security/password-policy.ts +77 -0
  121. package/src/infrastructure/security/token-blacklist.ts +45 -0
  122. package/src/infrastructure/security/token-service.ts +144 -0
  123. package/src/infrastructure/security/totp-service.ts +142 -0
  124. package/src/infrastructure/tracing/index.ts +7 -0
  125. package/src/infrastructure/tracing/trace-context.ts +93 -0
  126. package/src/main.ts +479 -0
  127. package/src/presentation/context.ts +26 -0
  128. package/src/presentation/handlers/admin.handler.ts +114 -0
  129. package/src/presentation/handlers/api-key.handler.ts +68 -0
  130. package/src/presentation/handlers/auth.handler.ts +218 -0
  131. package/src/presentation/handlers/health.handler.ts +27 -0
  132. package/src/presentation/handlers/index.ts +15 -0
  133. package/src/presentation/handlers/metrics.handler.ts +21 -0
  134. package/src/presentation/handlers/oauth.handler.ts +61 -0
  135. package/src/presentation/handlers/openapi.handler.ts +543 -0
  136. package/src/presentation/handlers/response.ts +29 -0
  137. package/src/presentation/handlers/sse.handler.ts +165 -0
  138. package/src/presentation/handlers/user.handler.ts +81 -0
  139. package/src/presentation/handlers/webhook.handler.ts +92 -0
  140. package/src/presentation/handlers/websocket.handler.ts +226 -0
  141. package/src/presentation/i18n/index.ts +254 -0
  142. package/src/presentation/index.ts +5 -0
  143. package/src/presentation/middleware/api-key.ts +18 -0
  144. package/src/presentation/middleware/auth.ts +39 -0
  145. package/src/presentation/middleware/cors.ts +41 -0
  146. package/src/presentation/middleware/index.ts +12 -0
  147. package/src/presentation/middleware/rate-limit.ts +65 -0
  148. package/src/presentation/middleware/security-headers.ts +18 -0
  149. package/src/presentation/middleware/validate.ts +16 -0
  150. package/src/presentation/middleware/versioning.ts +69 -0
  151. package/src/presentation/routes/index.ts +1 -0
  152. package/src/presentation/routes/router.ts +272 -0
  153. package/src/presentation/server.ts +381 -0
  154. package/src/shared/cli.ts +294 -0
  155. package/src/shared/container.ts +65 -0
  156. package/src/shared/index.ts +2 -0
  157. package/src/shared/log-format.ts +148 -0
  158. package/src/shared/utils/id.ts +5 -0
  159. package/src/shared/utils/index.ts +2 -0
  160. package/src/shared/utils/timing-safe.ts +20 -0
@@ -0,0 +1,165 @@
1
+ /**
2
+ * Server-Sent Events (SSE) handler — streaming endpoint for real-time updates.
3
+ *
4
+ * Clients connect to GET /api/v1/events/stream with optional query params:
5
+ * - token: JWT for authentication (required)
6
+ * - events: comma-separated event types to subscribe to (default: all)
7
+ *
8
+ * Protocol: standard SSE (text/event-stream), each event is:
9
+ * event: <type>
10
+ * id: <eventId>
11
+ * data: <JSON payload>
12
+ *
13
+ * Heartbeat "ping" comments sent every 30s to keep the connection alive.
14
+ */
15
+
16
+ import type { DomainEvent, EventBus } from "../../core/ports/event-bus.js";
17
+ import type { Logger } from "../../core/ports/logger.js";
18
+ import type { TokenService } from "../../core/ports/token-service.js";
19
+ import type { RequestContext } from "../context.js";
20
+
21
+ interface SseHandlerDeps {
22
+ readonly tokenService: TokenService;
23
+ readonly eventBus: EventBus;
24
+ readonly logger: Logger;
25
+ }
26
+
27
+ interface SseConnection {
28
+ readonly id: string;
29
+ readonly controller: ReadableStreamDefaultController;
30
+ readonly unsubscribe: () => void;
31
+ readonly heartbeat: ReturnType<typeof setInterval>;
32
+ }
33
+
34
+ export interface SseHandler {
35
+ /** Handle SSE connection request */
36
+ stream(req: Request, ctx: RequestContext): Promise<Response>;
37
+ /** Number of active SSE connections */
38
+ readonly connectionCount: number;
39
+ }
40
+
41
+ const HEARTBEAT_INTERVAL_MS = 30_000;
42
+
43
+ const encoder = new TextEncoder();
44
+
45
+ const formatSseMessage = (event: DomainEvent): string => {
46
+ const data = JSON.stringify({
47
+ id: event.id,
48
+ type: event.type,
49
+ timestamp: event.timestamp,
50
+ payload: event.payload,
51
+ });
52
+ return `event: ${event.type}\nid: ${event.id}\ndata: ${data}\n\n`;
53
+ };
54
+
55
+ export const createSseHandler = (deps: SseHandlerDeps): SseHandler => {
56
+ const { tokenService, eventBus, logger } = deps;
57
+
58
+ const connections = new Map<string, SseConnection>();
59
+
60
+ return {
61
+ async stream(req: Request, ctx: RequestContext): Promise<Response> {
62
+ // Authenticate via query param or Authorization header
63
+ const url = new URL(req.url);
64
+ const token =
65
+ url.searchParams.get("token") ?? req.headers.get("authorization")?.replace("Bearer ", "");
66
+
67
+ if (!token) {
68
+ return new Response(
69
+ JSON.stringify({ error: { code: "UNAUTHORIZED", message: "Missing token" } }),
70
+ { status: 401, headers: { "Content-Type": "application/json" } },
71
+ );
72
+ }
73
+
74
+ const authResult = await tokenService.verify(token);
75
+ if (!authResult.ok) {
76
+ return new Response(
77
+ JSON.stringify({ error: { code: "UNAUTHORIZED", message: "Invalid token" } }),
78
+ { status: 401, headers: { "Content-Type": "application/json" } },
79
+ );
80
+ }
81
+
82
+ // Parse event filter
83
+ const eventFilter = url.searchParams.get("events");
84
+ const subscribedEvents = eventFilter
85
+ ? new Set(eventFilter.split(",").map((e) => e.trim()))
86
+ : null;
87
+
88
+ const connectionId = ctx.requestId as string;
89
+
90
+ const stream = new ReadableStream({
91
+ start(controller) {
92
+ // Send initial connection message
93
+ controller.enqueue(
94
+ encoder.encode(
95
+ `: connected\nevent: connected\ndata: ${JSON.stringify({ connectionId })}\n\n`,
96
+ ),
97
+ );
98
+
99
+ // Subscribe to events
100
+ const handler = (event: DomainEvent): void => {
101
+ if (subscribedEvents && !subscribedEvents.has(event.type)) return;
102
+ try {
103
+ controller.enqueue(encoder.encode(formatSseMessage(event)));
104
+ } catch {
105
+ // Stream may have closed
106
+ }
107
+ };
108
+
109
+ // Subscribe to all events (filtering done in handler)
110
+ const unsubscribe = eventBus.subscribeAll(handler);
111
+
112
+ // Heartbeat to keep connection alive
113
+ const heartbeat = setInterval(() => {
114
+ try {
115
+ controller.enqueue(encoder.encode(": heartbeat\n\n"));
116
+ } catch {
117
+ // Stream closed
118
+ }
119
+ }, HEARTBEAT_INTERVAL_MS);
120
+
121
+ connections.set(connectionId, {
122
+ id: connectionId,
123
+ controller,
124
+ unsubscribe,
125
+ heartbeat,
126
+ });
127
+
128
+ logger.debug("SSE client connected", {
129
+ connectionId,
130
+ userId: authResult.value.sub,
131
+ events: subscribedEvents ? [...subscribedEvents] : "*",
132
+ totalConnections: connections.size,
133
+ });
134
+ },
135
+
136
+ cancel() {
137
+ const conn = connections.get(connectionId);
138
+ if (conn) {
139
+ conn.unsubscribe();
140
+ clearInterval(conn.heartbeat);
141
+ connections.delete(connectionId);
142
+ }
143
+ logger.debug("SSE client disconnected", {
144
+ connectionId,
145
+ totalConnections: connections.size,
146
+ });
147
+ },
148
+ });
149
+
150
+ return new Response(stream, {
151
+ status: 200,
152
+ headers: {
153
+ "Content-Type": "text/event-stream",
154
+ "Cache-Control": "no-cache",
155
+ Connection: "keep-alive",
156
+ "X-Accel-Buffering": "no", // Disable Nginx buffering
157
+ },
158
+ });
159
+ },
160
+
161
+ get connectionCount(): number {
162
+ return connections.size;
163
+ },
164
+ };
165
+ };
@@ -0,0 +1,81 @@
1
+ import { updateUserDto } from "../../application/dtos/auth.dto.js";
2
+ import type { UserService } from "../../application/services/user.service.js";
3
+ import type { Logger } from "../../core/ports/logger.js";
4
+ import type { TokenService } from "../../core/ports/token-service.js";
5
+ import type { RequestContext } from "../context.js";
6
+ import { authenticate } from "../middleware/auth.js";
7
+ import { validateBody } from "../middleware/validate.js";
8
+ import { errorResponse, jsonResponse, noContentResponse } from "./response.js";
9
+
10
+ export const userHandlers = (
11
+ userService: UserService,
12
+ tokenService: TokenService,
13
+ logger: Logger,
14
+ ) => ({
15
+ getMe: async (req: Request, ctx: RequestContext): Promise<Response> => {
16
+ const authResult = await authenticate(req, tokenService);
17
+ if (!authResult.ok) {
18
+ logger.warn("Auth failed on getMe", {
19
+ requestId: ctx.requestId,
20
+ code: authResult.error.code,
21
+ });
22
+ return errorResponse(authResult.error, ctx.requestId);
23
+ }
24
+
25
+ const result = await userService.getById(authResult.value.sub);
26
+ if (!result.ok) {
27
+ logger.warn("getMe failed", { requestId: ctx.requestId, code: result.error.code });
28
+ return errorResponse(result.error, ctx.requestId);
29
+ }
30
+
31
+ return jsonResponse(result.value);
32
+ },
33
+
34
+ updateMe: async (req: Request, ctx: RequestContext): Promise<Response> => {
35
+ const authResult = await authenticate(req, tokenService);
36
+ if (!authResult.ok) {
37
+ logger.warn("Auth failed on updateMe", {
38
+ requestId: ctx.requestId,
39
+ code: authResult.error.code,
40
+ });
41
+ return errorResponse(authResult.error, ctx.requestId);
42
+ }
43
+
44
+ const body = await req.json().catch(() => null);
45
+ const validated = validateBody(updateUserDto, body);
46
+ if (!validated.ok) {
47
+ logger.warn("Update validation failed", {
48
+ requestId: ctx.requestId,
49
+ code: validated.error.code,
50
+ });
51
+ return errorResponse(validated.error, ctx.requestId);
52
+ }
53
+
54
+ const result = await userService.update(authResult.value.sub, validated.value);
55
+ if (!result.ok) {
56
+ logger.warn("updateMe failed", { requestId: ctx.requestId, code: result.error.code });
57
+ return errorResponse(result.error, ctx.requestId);
58
+ }
59
+
60
+ return jsonResponse(result.value);
61
+ },
62
+
63
+ deleteMe: async (req: Request, ctx: RequestContext): Promise<Response> => {
64
+ const authResult = await authenticate(req, tokenService);
65
+ if (!authResult.ok) {
66
+ logger.warn("Auth failed on deleteMe", {
67
+ requestId: ctx.requestId,
68
+ code: authResult.error.code,
69
+ });
70
+ return errorResponse(authResult.error, ctx.requestId);
71
+ }
72
+
73
+ const result = await userService.remove(authResult.value.sub);
74
+ if (!result.ok) {
75
+ logger.warn("deleteMe failed", { requestId: ctx.requestId, code: result.error.code });
76
+ return errorResponse(result.error, ctx.requestId);
77
+ }
78
+
79
+ return noContentResponse();
80
+ },
81
+ });
@@ -0,0 +1,92 @@
1
+ /**
2
+ * Webhook management API handlers.
3
+ *
4
+ * POST /api/v1/webhooks — Create a webhook subscription
5
+ * GET /api/v1/webhooks — List webhook subscriptions
6
+ * DELETE /api/v1/webhooks/:id — Remove a webhook subscription
7
+ */
8
+
9
+ import { UserRole } from "../../core/entities/user.entity.js";
10
+ import type { DomainEventType } from "../../core/ports/event-bus.js";
11
+ import type { Logger } from "../../core/ports/logger.js";
12
+ import type { TokenService } from "../../core/ports/token-service.js";
13
+ import type { WebhookRegistry } from "../../core/ports/webhook.js";
14
+ import type { RequestContext } from "../context.js";
15
+ import { authenticate, authorise } from "../middleware/auth.js";
16
+ import { errorResponse, jsonResponse } from "./response.js";
17
+
18
+ export const webhookHandlers = (
19
+ registry: WebhookRegistry,
20
+ tokenService: TokenService,
21
+ logger: Logger,
22
+ ) => {
23
+ const requireAdmin = async (req: Request, _ctx: RequestContext) => {
24
+ const authResult = await authenticate(req, tokenService);
25
+ if (!authResult.ok) return authResult;
26
+ return authorise(authResult.value, [UserRole.ADMIN]);
27
+ };
28
+
29
+ return {
30
+ async create(req: Request, ctx: RequestContext): Promise<Response> {
31
+ const authResult = await requireAdmin(req, ctx);
32
+ if (!authResult.ok) return errorResponse(authResult.error, ctx.requestId);
33
+
34
+ const body = (await req.json().catch(() => null)) as {
35
+ url?: string;
36
+ events?: string[];
37
+ secret?: string;
38
+ } | null;
39
+
40
+ if (!body || !body.url || typeof body.url !== "string") {
41
+ return errorResponse(
42
+ { code: "VALIDATION" as const, message: "url is required" },
43
+ ctx.requestId,
44
+ );
45
+ }
46
+ if (!body.secret || typeof body.secret !== "string") {
47
+ return errorResponse(
48
+ { code: "VALIDATION" as const, message: "secret is required" },
49
+ ctx.requestId,
50
+ );
51
+ }
52
+
53
+ const events = body.events ?? [];
54
+ const result = registry.create({
55
+ url: body.url,
56
+ events: events as ReadonlyArray<DomainEventType>,
57
+ secret: body.secret,
58
+ });
59
+
60
+ if (!result.ok) return errorResponse(result.error, ctx.requestId);
61
+
62
+ logger.info("Webhook subscription created", {
63
+ webhookId: result.value.id,
64
+ url: result.value.url,
65
+ events,
66
+ });
67
+
68
+ return Response.json({ data: result.value }, { status: 201 });
69
+ },
70
+
71
+ async list(req: Request, ctx: RequestContext): Promise<Response> {
72
+ const authResult = await requireAdmin(req, ctx);
73
+ if (!authResult.ok) return errorResponse(authResult.error, ctx.requestId);
74
+
75
+ const result = registry.list();
76
+ if (!result.ok) return errorResponse(result.error, ctx.requestId);
77
+
78
+ return jsonResponse({ webhooks: result.value });
79
+ },
80
+
81
+ async remove(req: Request, ctx: RequestContext, webhookId: string): Promise<Response> {
82
+ const authResult = await requireAdmin(req, ctx);
83
+ if (!authResult.ok) return errorResponse(authResult.error, ctx.requestId);
84
+
85
+ const result = registry.remove(webhookId);
86
+ if (!result.ok) return errorResponse(result.error, ctx.requestId);
87
+
88
+ logger.info("Webhook subscription removed", { webhookId });
89
+ return jsonResponse({ deleted: true });
90
+ },
91
+ };
92
+ };
@@ -0,0 +1,226 @@
1
+ /**
2
+ * WebSocket manager — handles Bun-native WebSocket connections.
3
+ *
4
+ * Supports:
5
+ * - Connection lifecycle (open, close, message)
6
+ * - Per-connection authentication (JWT in query string or first message)
7
+ * - Topic-based pub/sub (subscribe to event types)
8
+ * - Broadcasting domain events to subscribed clients
9
+ */
10
+
11
+ import type { ServerWebSocket } from "bun";
12
+ import type { DomainEvent, EventBus } from "../../core/ports/event-bus.js";
13
+ import type { Logger } from "../../core/ports/logger.js";
14
+ import type { TokenPayload, TokenService } from "../../core/ports/token-service.js";
15
+
16
+ export interface WsConnectionData {
17
+ readonly id: string;
18
+ auth: TokenPayload | null;
19
+ readonly connectedAt: number;
20
+ readonly ip: string;
21
+ subscriptions: Set<string>;
22
+ }
23
+
24
+ export interface WebSocketManager {
25
+ /** Handle WebSocket upgrade request. Returns upgrade data or null if rejected. */
26
+ handleUpgrade(req: Request, ip: string): WsConnectionData | null;
27
+
28
+ /** Called when a WebSocket connection opens */
29
+ onOpen(ws: ServerWebSocket<WsConnectionData>): void;
30
+
31
+ /** Called when a message is received */
32
+ onMessage(ws: ServerWebSocket<WsConnectionData>, message: string | Buffer): void;
33
+
34
+ /** Called when a WebSocket connection closes */
35
+ onClose(ws: ServerWebSocket<WsConnectionData>, code: number, reason: string): void;
36
+
37
+ /** Broadcast a domain event to all subscribed WebSocket clients */
38
+ broadcast(event: DomainEvent): void;
39
+
40
+ /** Number of active connections */
41
+ readonly connectionCount: number;
42
+ }
43
+
44
+ interface WsManagerDeps {
45
+ readonly tokenService: TokenService;
46
+ readonly eventBus: EventBus;
47
+ readonly logger: Logger;
48
+ }
49
+
50
+ /**
51
+ * Client-to-server message protocol:
52
+ *
53
+ * { "type": "auth", "token": "<JWT>" }
54
+ * { "type": "subscribe", "events": ["user.registered", "login.failed"] }
55
+ * { "type": "unsubscribe", "events": ["user.registered"] }
56
+ * { "type": "ping" }
57
+ *
58
+ * Server-to-client message protocol:
59
+ *
60
+ * { "type": "auth", "ok": true, "userId": "..." }
61
+ * { "type": "auth", "ok": false, "error": "Invalid token" }
62
+ * { "type": "subscribed", "events": ["user.registered"] }
63
+ * { "type": "event", "event": { ... } }
64
+ * { "type": "pong" }
65
+ * { "type": "error", "message": "..." }
66
+ */
67
+
68
+ interface ClientMessage {
69
+ readonly type: string;
70
+ readonly token?: string | undefined;
71
+ readonly events?: ReadonlyArray<string> | undefined;
72
+ }
73
+
74
+ export const createWebSocketManager = (deps: WsManagerDeps): WebSocketManager => {
75
+ const { tokenService, logger } = deps;
76
+
77
+ const connections = new Set<ServerWebSocket<WsConnectionData>>();
78
+
79
+ const sendJson = (ws: ServerWebSocket<WsConnectionData>, data: Record<string, unknown>): void => {
80
+ try {
81
+ ws.send(JSON.stringify(data));
82
+ } catch {
83
+ // Connection may have closed
84
+ }
85
+ };
86
+
87
+ const authenticateToken = async (token: string): Promise<TokenPayload | null> => {
88
+ const result = await tokenService.verify(token);
89
+ return result.ok ? result.value : null;
90
+ };
91
+
92
+ return {
93
+ handleUpgrade(req: Request, ip: string): WsConnectionData | null {
94
+ // Extract token from query string for initial auth (optional)
95
+ const url = new URL(req.url);
96
+ const token = url.searchParams.get("token");
97
+
98
+ const data: WsConnectionData = {
99
+ id: crypto.randomUUID(),
100
+ auth: null,
101
+ connectedAt: Date.now(),
102
+ ip,
103
+ subscriptions: new Set(),
104
+ };
105
+
106
+ // If token provided in URL, validate later in onOpen
107
+ if (token) {
108
+ // Store token temporarily — will authenticate in onOpen
109
+ (data as { auth: TokenPayload | null }).auth = null;
110
+ }
111
+
112
+ return data;
113
+ },
114
+
115
+ onOpen(ws: ServerWebSocket<WsConnectionData>): void {
116
+ connections.add(ws);
117
+ logger.debug("WebSocket connected", {
118
+ connectionId: ws.data.id,
119
+ ip: ws.data.ip,
120
+ totalConnections: connections.size,
121
+ });
122
+
123
+ sendJson(ws, {
124
+ type: "connected",
125
+ connectionId: ws.data.id,
126
+ message: 'Authenticate with { "type": "auth", "token": "<JWT>" }',
127
+ });
128
+ },
129
+
130
+ // biome-ignore lint/complexity/noExcessiveCognitiveComplexity: WebSocket message handling has many branches
131
+ onMessage(ws: ServerWebSocket<WsConnectionData>, message: string | Buffer): void {
132
+ const raw = typeof message === "string" ? message : message.toString();
133
+
134
+ let msg: ClientMessage;
135
+ try {
136
+ msg = JSON.parse(raw) as ClientMessage;
137
+ } catch {
138
+ sendJson(ws, { type: "error", message: "Invalid JSON" });
139
+ return;
140
+ }
141
+
142
+ switch (msg.type) {
143
+ case "ping":
144
+ sendJson(ws, { type: "pong" });
145
+ break;
146
+
147
+ case "auth": {
148
+ if (!msg.token) {
149
+ sendJson(ws, { type: "auth", ok: false, error: "Missing token" });
150
+ return;
151
+ }
152
+ authenticateToken(msg.token).then((payload) => {
153
+ if (payload) {
154
+ ws.data.auth = payload;
155
+ sendJson(ws, { type: "auth", ok: true, userId: payload.sub });
156
+ logger.debug("WebSocket authenticated", {
157
+ connectionId: ws.data.id,
158
+ userId: payload.sub,
159
+ });
160
+ } else {
161
+ sendJson(ws, { type: "auth", ok: false, error: "Invalid token" });
162
+ }
163
+ });
164
+ break;
165
+ }
166
+
167
+ case "subscribe": {
168
+ if (!ws.data.auth) {
169
+ sendJson(ws, { type: "error", message: "Not authenticated" });
170
+ return;
171
+ }
172
+ const events = msg.events ?? [];
173
+ for (const event of events) {
174
+ ws.data.subscriptions.add(event);
175
+ }
176
+ sendJson(ws, { type: "subscribed", events: [...ws.data.subscriptions] });
177
+ break;
178
+ }
179
+
180
+ case "unsubscribe": {
181
+ const events = msg.events ?? [];
182
+ for (const event of events) {
183
+ ws.data.subscriptions.delete(event);
184
+ }
185
+ sendJson(ws, { type: "subscribed", events: [...ws.data.subscriptions] });
186
+ break;
187
+ }
188
+
189
+ default:
190
+ sendJson(ws, { type: "error", message: `Unknown message type: ${msg.type}` });
191
+ }
192
+ },
193
+
194
+ onClose(ws: ServerWebSocket<WsConnectionData>, code: number, reason: string): void {
195
+ connections.delete(ws);
196
+ logger.debug("WebSocket disconnected", {
197
+ connectionId: ws.data.id,
198
+ code,
199
+ reason,
200
+ totalConnections: connections.size,
201
+ });
202
+ },
203
+
204
+ broadcast(event: DomainEvent): void {
205
+ const payload = JSON.stringify({ type: "event", event });
206
+
207
+ for (const ws of connections) {
208
+ // Only send to authenticated clients subscribed to this event type
209
+ if (
210
+ ws.data.auth &&
211
+ (ws.data.subscriptions.has(event.type) || ws.data.subscriptions.has("*"))
212
+ ) {
213
+ try {
214
+ ws.send(payload);
215
+ } catch {
216
+ // Connection may have closed
217
+ }
218
+ }
219
+ }
220
+ },
221
+
222
+ get connectionCount(): number {
223
+ return connections.size;
224
+ },
225
+ };
226
+ };