@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.
- package/CHANGELOG.md +201 -0
- package/LICENSE +21 -0
- package/README.md +338 -0
- package/dist/cli.js +14 -0
- package/package.json +69 -0
- package/src/application/dtos/admin.dto.ts +25 -0
- package/src/application/dtos/auth.dto.ts +97 -0
- package/src/application/dtos/index.ts +40 -0
- package/src/application/index.ts +2 -0
- package/src/application/services/admin.service.ts +150 -0
- package/src/application/services/api-key.service.ts +65 -0
- package/src/application/services/auth.service.ts +606 -0
- package/src/application/services/health.service.ts +97 -0
- package/src/application/services/index.ts +10 -0
- package/src/application/services/user.service.ts +95 -0
- package/src/cli/commands/help.ts +86 -0
- package/src/cli/commands/init.ts +301 -0
- package/src/cli/commands/upgrade.ts +471 -0
- package/src/cli/index.ts +76 -0
- package/src/cli/ui.ts +189 -0
- package/src/cluster.ts +62 -0
- package/src/core/entities/index.ts +1 -0
- package/src/core/entities/user.entity.ts +24 -0
- package/src/core/errors/app-error.ts +81 -0
- package/src/core/errors/index.ts +15 -0
- package/src/core/index.ts +7 -0
- package/src/core/ports/account-lockout.ts +15 -0
- package/src/core/ports/alert-sink.ts +27 -0
- package/src/core/ports/api-key.ts +37 -0
- package/src/core/ports/audit-log.ts +46 -0
- package/src/core/ports/cache.ts +24 -0
- package/src/core/ports/circuit-breaker.ts +42 -0
- package/src/core/ports/event-bus.ts +78 -0
- package/src/core/ports/index.ts +62 -0
- package/src/core/ports/job-queue.ts +73 -0
- package/src/core/ports/logger.ts +21 -0
- package/src/core/ports/metrics.ts +49 -0
- package/src/core/ports/oauth.ts +55 -0
- package/src/core/ports/password-hasher.ts +10 -0
- package/src/core/ports/password-history.ts +23 -0
- package/src/core/ports/password-policy.ts +43 -0
- package/src/core/ports/refresh-token-store.ts +37 -0
- package/src/core/ports/retry.ts +23 -0
- package/src/core/ports/token-blacklist.ts +16 -0
- package/src/core/ports/token-service.ts +23 -0
- package/src/core/ports/totp-service.ts +16 -0
- package/src/core/ports/user.repository.ts +40 -0
- package/src/core/ports/verification-token.ts +41 -0
- package/src/core/ports/webhook.ts +58 -0
- package/src/core/types/brand.ts +19 -0
- package/src/core/types/index.ts +19 -0
- package/src/core/types/pagination.ts +28 -0
- package/src/core/types/result.ts +52 -0
- package/src/infrastructure/alerting/index.ts +1 -0
- package/src/infrastructure/alerting/webhook.ts +100 -0
- package/src/infrastructure/cache/in-memory-cache.ts +111 -0
- package/src/infrastructure/cache/index.ts +6 -0
- package/src/infrastructure/cache/redis-cache.ts +204 -0
- package/src/infrastructure/config/config.ts +185 -0
- package/src/infrastructure/config/index.ts +1 -0
- package/src/infrastructure/database/in-memory-user.repository.ts +134 -0
- package/src/infrastructure/database/index.ts +37 -0
- package/src/infrastructure/database/migrations/001_create_users.ts +26 -0
- package/src/infrastructure/database/migrations/002_create_token_blacklist.ts +21 -0
- package/src/infrastructure/database/migrations/003_create_audit_log.ts +31 -0
- package/src/infrastructure/database/migrations/004_auth_platform.ts +112 -0
- package/src/infrastructure/database/migrations/runner.ts +120 -0
- package/src/infrastructure/database/mssql/index.ts +14 -0
- package/src/infrastructure/database/mssql/migrations.ts +299 -0
- package/src/infrastructure/database/mssql/mssql-account-lockout.ts +95 -0
- package/src/infrastructure/database/mssql/mssql-api-keys.ts +146 -0
- package/src/infrastructure/database/mssql/mssql-audit-log.ts +86 -0
- package/src/infrastructure/database/mssql/mssql-oauth-accounts.ts +118 -0
- package/src/infrastructure/database/mssql/mssql-password-history.ts +71 -0
- package/src/infrastructure/database/mssql/mssql-refresh-token-store.ts +144 -0
- package/src/infrastructure/database/mssql/mssql-token-blacklist.ts +54 -0
- package/src/infrastructure/database/mssql/mssql-user.repository.ts +263 -0
- package/src/infrastructure/database/mssql/mssql-verification-tokens.ts +120 -0
- package/src/infrastructure/database/postgres/index.ts +14 -0
- package/src/infrastructure/database/postgres/migrations.ts +235 -0
- package/src/infrastructure/database/postgres/pg-account-lockout.ts +75 -0
- package/src/infrastructure/database/postgres/pg-api-keys.ts +126 -0
- package/src/infrastructure/database/postgres/pg-audit-log.ts +74 -0
- package/src/infrastructure/database/postgres/pg-oauth-accounts.ts +101 -0
- package/src/infrastructure/database/postgres/pg-password-history.ts +61 -0
- package/src/infrastructure/database/postgres/pg-refresh-token-store.ts +117 -0
- package/src/infrastructure/database/postgres/pg-token-blacklist.ts +48 -0
- package/src/infrastructure/database/postgres/pg-user.repository.ts +237 -0
- package/src/infrastructure/database/postgres/pg-verification-tokens.ts +97 -0
- package/src/infrastructure/database/sqlite-account-lockout.ts +97 -0
- package/src/infrastructure/database/sqlite-api-keys.ts +155 -0
- package/src/infrastructure/database/sqlite-audit-log.ts +90 -0
- package/src/infrastructure/database/sqlite-oauth-accounts.ts +105 -0
- package/src/infrastructure/database/sqlite-password-history.ts +54 -0
- package/src/infrastructure/database/sqlite-refresh-token-store.ts +122 -0
- package/src/infrastructure/database/sqlite-token-blacklist.ts +47 -0
- package/src/infrastructure/database/sqlite-user.repository.ts +260 -0
- package/src/infrastructure/database/sqlite-verification-tokens.ts +112 -0
- package/src/infrastructure/events/event-bus.ts +105 -0
- package/src/infrastructure/events/event-factory.ts +31 -0
- package/src/infrastructure/events/in-memory-webhook-registry.ts +67 -0
- package/src/infrastructure/events/index.ts +4 -0
- package/src/infrastructure/events/webhook-dispatcher.ts +114 -0
- package/src/infrastructure/index.ts +58 -0
- package/src/infrastructure/jobs/index.ts +1 -0
- package/src/infrastructure/jobs/job-queue.ts +185 -0
- package/src/infrastructure/logging/index.ts +1 -0
- package/src/infrastructure/logging/logger.ts +63 -0
- package/src/infrastructure/metrics/index.ts +1 -0
- package/src/infrastructure/metrics/prometheus.ts +231 -0
- package/src/infrastructure/oauth/github.ts +116 -0
- package/src/infrastructure/oauth/google.ts +83 -0
- package/src/infrastructure/oauth/index.ts +2 -0
- package/src/infrastructure/resilience/circuit-breaker.ts +133 -0
- package/src/infrastructure/resilience/index.ts +2 -0
- package/src/infrastructure/resilience/retry.ts +50 -0
- package/src/infrastructure/security/account-lockout.ts +73 -0
- package/src/infrastructure/security/index.ts +6 -0
- package/src/infrastructure/security/password-hasher.ts +31 -0
- package/src/infrastructure/security/password-policy.ts +77 -0
- package/src/infrastructure/security/token-blacklist.ts +45 -0
- package/src/infrastructure/security/token-service.ts +144 -0
- package/src/infrastructure/security/totp-service.ts +142 -0
- package/src/infrastructure/tracing/index.ts +7 -0
- package/src/infrastructure/tracing/trace-context.ts +93 -0
- package/src/main.ts +479 -0
- package/src/presentation/context.ts +26 -0
- package/src/presentation/handlers/admin.handler.ts +114 -0
- package/src/presentation/handlers/api-key.handler.ts +68 -0
- package/src/presentation/handlers/auth.handler.ts +218 -0
- package/src/presentation/handlers/health.handler.ts +27 -0
- package/src/presentation/handlers/index.ts +15 -0
- package/src/presentation/handlers/metrics.handler.ts +21 -0
- package/src/presentation/handlers/oauth.handler.ts +61 -0
- package/src/presentation/handlers/openapi.handler.ts +543 -0
- package/src/presentation/handlers/response.ts +29 -0
- package/src/presentation/handlers/sse.handler.ts +165 -0
- package/src/presentation/handlers/user.handler.ts +81 -0
- package/src/presentation/handlers/webhook.handler.ts +92 -0
- package/src/presentation/handlers/websocket.handler.ts +226 -0
- package/src/presentation/i18n/index.ts +254 -0
- package/src/presentation/index.ts +5 -0
- package/src/presentation/middleware/api-key.ts +18 -0
- package/src/presentation/middleware/auth.ts +39 -0
- package/src/presentation/middleware/cors.ts +41 -0
- package/src/presentation/middleware/index.ts +12 -0
- package/src/presentation/middleware/rate-limit.ts +65 -0
- package/src/presentation/middleware/security-headers.ts +18 -0
- package/src/presentation/middleware/validate.ts +16 -0
- package/src/presentation/middleware/versioning.ts +69 -0
- package/src/presentation/routes/index.ts +1 -0
- package/src/presentation/routes/router.ts +272 -0
- package/src/presentation/server.ts +381 -0
- package/src/shared/cli.ts +294 -0
- package/src/shared/container.ts +65 -0
- package/src/shared/index.ts +2 -0
- package/src/shared/log-format.ts +148 -0
- package/src/shared/utils/id.ts +5 -0
- package/src/shared/utils/index.ts +2 -0
- 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
|
+
};
|