@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,294 @@
|
|
|
1
|
+
import { cpus } from "node:os";
|
|
2
|
+
import type { AppConfig } from "../infrastructure/config/config.js";
|
|
3
|
+
|
|
4
|
+
// ── ANSI escape sequences (zero dependencies) ──────────────────────────
|
|
5
|
+
|
|
6
|
+
const esc = (code: string) => `\x1b[${code}m`;
|
|
7
|
+
const reset = esc("0");
|
|
8
|
+
|
|
9
|
+
const bold = (s: string) => `${esc("1")}${s}${reset}`;
|
|
10
|
+
const dim = (s: string) => `${esc("2")}${s}${reset}`;
|
|
11
|
+
|
|
12
|
+
const cyan = (s: string) => `${esc("36")}${s}${reset}`;
|
|
13
|
+
const green = (s: string) => `${esc("32")}${s}${reset}`;
|
|
14
|
+
const yellow = (s: string) => `${esc("33")}${s}${reset}`;
|
|
15
|
+
const magenta = (s: string) => `${esc("35")}${s}${reset}`;
|
|
16
|
+
const blue = (s: string) => `${esc("34")}${s}${reset}`;
|
|
17
|
+
const red = (s: string) => `${esc("31")}${s}${reset}`;
|
|
18
|
+
const gray = (s: string) => `${esc("90")}${s}${reset}`;
|
|
19
|
+
const white = (s: string) => `${esc("97")}${s}${reset}`;
|
|
20
|
+
|
|
21
|
+
const bgCyan = (s: string) => `${esc("46")}${esc("30")} ${s} ${reset}`;
|
|
22
|
+
const bgGreen = (s: string) => `${esc("42")}${esc("30")} ${s} ${reset}`;
|
|
23
|
+
const bgYellow = (s: string) => `${esc("43")}${esc("30")} ${s} ${reset}`;
|
|
24
|
+
const bgMagenta = (s: string) => `${esc("45")}${esc("97")} ${s} ${reset}`;
|
|
25
|
+
|
|
26
|
+
// ── Helpers ─────────────────────────────────────────────────────────────
|
|
27
|
+
|
|
28
|
+
const pad = (s: string, len: number): string => s.padEnd(len);
|
|
29
|
+
|
|
30
|
+
const formatUptime = (ms: number): string => {
|
|
31
|
+
if (ms < 1000) return `${Math.round(ms)}ms`;
|
|
32
|
+
return `${(ms / 1000).toFixed(2)}s`;
|
|
33
|
+
};
|
|
34
|
+
|
|
35
|
+
const envBadge = (env: string): string => {
|
|
36
|
+
switch (env) {
|
|
37
|
+
case "production":
|
|
38
|
+
return bgGreen("PRODUCTION");
|
|
39
|
+
case "development":
|
|
40
|
+
return bgCyan("DEVELOPMENT");
|
|
41
|
+
case "test":
|
|
42
|
+
return bgYellow("TEST");
|
|
43
|
+
default:
|
|
44
|
+
return bgMagenta(env.toUpperCase());
|
|
45
|
+
}
|
|
46
|
+
};
|
|
47
|
+
|
|
48
|
+
const methodColor = (method: string): string => {
|
|
49
|
+
switch (method) {
|
|
50
|
+
case "GET":
|
|
51
|
+
return green(bold(pad(method, 7)));
|
|
52
|
+
case "POST":
|
|
53
|
+
return cyan(bold(pad(method, 7)));
|
|
54
|
+
case "PATCH":
|
|
55
|
+
return yellow(bold(pad(method, 7)));
|
|
56
|
+
case "PUT":
|
|
57
|
+
return yellow(bold(pad(method, 7)));
|
|
58
|
+
case "DELETE":
|
|
59
|
+
return red(bold(pad(method, 7)));
|
|
60
|
+
default:
|
|
61
|
+
return white(bold(pad(method, 7)));
|
|
62
|
+
}
|
|
63
|
+
};
|
|
64
|
+
|
|
65
|
+
// ── Route table ─────────────────────────────────────────────────────────
|
|
66
|
+
|
|
67
|
+
interface RouteInfo {
|
|
68
|
+
readonly method: string;
|
|
69
|
+
readonly path: string;
|
|
70
|
+
readonly auth: boolean;
|
|
71
|
+
readonly description: string;
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
const routes: readonly RouteInfo[] = [
|
|
75
|
+
{ method: "GET", path: "/health", auth: false, description: "Shallow health check" },
|
|
76
|
+
{ method: "GET", path: "/readiness", auth: false, description: "Deep readiness check" },
|
|
77
|
+
{ method: "GET", path: "/docs", auth: false, description: "OpenAPI 3.1 spec (JSON)" },
|
|
78
|
+
{ method: "GET", path: "/docs/html", auth: false, description: "Swagger UI" },
|
|
79
|
+
{ method: "POST", path: "/api/v1/auth/register", auth: false, description: "Register user" },
|
|
80
|
+
{ method: "POST", path: "/api/v1/auth/login", auth: false, description: "Login" },
|
|
81
|
+
{ method: "POST", path: "/api/v1/auth/refresh", auth: false, description: "Refresh token" },
|
|
82
|
+
{ method: "POST", path: "/api/v1/auth/logout", auth: true, description: "Logout" },
|
|
83
|
+
{ method: "GET", path: "/api/v1/users/me", auth: true, description: "Get profile" },
|
|
84
|
+
{ method: "PATCH", path: "/api/v1/users/me", auth: true, description: "Update profile" },
|
|
85
|
+
{ method: "DELETE", path: "/api/v1/users/me", auth: true, description: "Delete account" },
|
|
86
|
+
{ method: "GET", path: "/api/v1/admin/users", auth: true, description: "List users (admin)" },
|
|
87
|
+
{ method: "GET", path: "/api/v1/admin/users/:id", auth: true, description: "Get user (admin)" },
|
|
88
|
+
{
|
|
89
|
+
method: "PATCH",
|
|
90
|
+
path: "/api/v1/admin/users/:id/role",
|
|
91
|
+
auth: true,
|
|
92
|
+
description: "Change role (admin)",
|
|
93
|
+
},
|
|
94
|
+
{
|
|
95
|
+
method: "POST",
|
|
96
|
+
path: "/api/v1/admin/users/:id/ban",
|
|
97
|
+
auth: true,
|
|
98
|
+
description: "Ban user (admin)",
|
|
99
|
+
},
|
|
100
|
+
{
|
|
101
|
+
method: "POST",
|
|
102
|
+
path: "/api/v1/admin/users/:id/unban",
|
|
103
|
+
auth: true,
|
|
104
|
+
description: "Unban user (admin)",
|
|
105
|
+
},
|
|
106
|
+
{
|
|
107
|
+
method: "GET",
|
|
108
|
+
path: "/metrics",
|
|
109
|
+
auth: false,
|
|
110
|
+
description: "Prometheus metrics",
|
|
111
|
+
},
|
|
112
|
+
];
|
|
113
|
+
|
|
114
|
+
// ── ASCII Logo ──────────────────────────────────────────────────────────
|
|
115
|
+
|
|
116
|
+
const logo = (): string => {
|
|
117
|
+
const lines = [
|
|
118
|
+
`${bold(cyan(" ┌─────────────────────────────────────────┐"))}`,
|
|
119
|
+
`${bold(cyan(" │"))} ${bold(cyan("│"))}`,
|
|
120
|
+
`${bold(cyan(" │"))} ${bold(white("⚡ onlyApi"))} ${dim(gray("v1.3.0"))} ${bold(cyan("│"))}`,
|
|
121
|
+
`${bold(cyan(" │"))} ${dim(gray("Zero-dep enterprise REST API on Bun"))} ${bold(cyan("│"))}`,
|
|
122
|
+
`${bold(cyan(" │"))} ${bold(cyan("│"))}`,
|
|
123
|
+
`${bold(cyan(" └─────────────────────────────────────────┘"))}`,
|
|
124
|
+
];
|
|
125
|
+
return lines.join("\n");
|
|
126
|
+
};
|
|
127
|
+
|
|
128
|
+
// ── Public API ──────────────────────────────────────────────────────────
|
|
129
|
+
|
|
130
|
+
interface StartupInfo {
|
|
131
|
+
readonly config: AppConfig;
|
|
132
|
+
readonly bootTimeMs: number;
|
|
133
|
+
readonly isCluster?: boolean;
|
|
134
|
+
readonly workerId?: string;
|
|
135
|
+
readonly workerCount?: number;
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
/**
|
|
139
|
+
* Prints a beautiful startup banner to stdout.
|
|
140
|
+
* Called once after the server is fully initialized.
|
|
141
|
+
*/
|
|
142
|
+
export const printStartupBanner = (info: StartupInfo): void => {
|
|
143
|
+
const { config, bootTimeMs } = info;
|
|
144
|
+
const isCluster = info.isCluster ?? false;
|
|
145
|
+
const workerId = info.workerId;
|
|
146
|
+
|
|
147
|
+
const localUrl = `http://localhost:${config.port}`;
|
|
148
|
+
const networkUrl = `http://${config.host === "0.0.0.0" ? getLocalIp() : config.host}:${config.port}`;
|
|
149
|
+
|
|
150
|
+
const lines: string[] = [];
|
|
151
|
+
|
|
152
|
+
lines.push("");
|
|
153
|
+
lines.push(logo());
|
|
154
|
+
lines.push("");
|
|
155
|
+
|
|
156
|
+
// ── Server info ──
|
|
157
|
+
lines.push(
|
|
158
|
+
` ${envBadge(config.env)} ${dim("booted in")} ${bold(green(formatUptime(bootTimeMs)))}`,
|
|
159
|
+
);
|
|
160
|
+
lines.push("");
|
|
161
|
+
|
|
162
|
+
// ── URLs ──
|
|
163
|
+
lines.push(` ${bold(white("→"))} ${dim("Local:")} ${bold(cyan(localUrl))}`);
|
|
164
|
+
if (config.host === "0.0.0.0") {
|
|
165
|
+
lines.push(` ${bold(white("→"))} ${dim("Network:")} ${bold(cyan(networkUrl))}`);
|
|
166
|
+
}
|
|
167
|
+
lines.push("");
|
|
168
|
+
|
|
169
|
+
// ── Process info ──
|
|
170
|
+
lines.push(` ${gray("├─")} ${dim("PID")} ${white(String(process.pid))}`);
|
|
171
|
+
lines.push(` ${gray("├─")} ${dim("Runtime")} ${magenta(`Bun ${Bun.version}`)}`);
|
|
172
|
+
lines.push(` ${gray("├─")} ${dim("TypeScript")} ${blue("strict")} ${dim("(22+ flags)")}`);
|
|
173
|
+
|
|
174
|
+
if (isCluster) {
|
|
175
|
+
const count = info.workerCount ?? cpus().length;
|
|
176
|
+
lines.push(` ${gray("├─")} ${dim("Mode")} ${yellow(`cluster × ${count} workers`)}`);
|
|
177
|
+
if (workerId !== undefined) {
|
|
178
|
+
lines.push(` ${gray("├─")} ${dim("Worker")} ${yellow(`#${workerId}`)}`);
|
|
179
|
+
}
|
|
180
|
+
} else {
|
|
181
|
+
lines.push(` ${gray("├─")} ${dim("Mode")} ${green("single process")}`);
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
lines.push(
|
|
185
|
+
` ${gray("├─")} ${dim("Rate limit")} ${white(`${config.rateLimit.maxRequests} req/${config.rateLimit.windowMs / 1000}s`)}`,
|
|
186
|
+
);
|
|
187
|
+
lines.push(` ${gray("├─")} ${dim("Log level")} ${white(config.log.level)}`);
|
|
188
|
+
lines.push(` ${gray("└─")} ${dim("Log format")} ${white(config.log.format)}`);
|
|
189
|
+
lines.push("");
|
|
190
|
+
|
|
191
|
+
// ── Routes ──
|
|
192
|
+
lines.push(` ${bold(white("Routes"))} ${dim(`(${routes.length})`)}`);
|
|
193
|
+
lines.push(` ${gray("─".repeat(60))}`);
|
|
194
|
+
|
|
195
|
+
for (const route of routes) {
|
|
196
|
+
const lock = route.auth ? yellow("🔒") : green(" ");
|
|
197
|
+
const desc = dim(gray(route.description));
|
|
198
|
+
lines.push(` ${lock} ${methodColor(route.method)} ${pad(route.path, 30)} ${desc}`);
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
lines.push(` ${gray("─".repeat(60))}`);
|
|
202
|
+
lines.push("");
|
|
203
|
+
|
|
204
|
+
// ── Help ──
|
|
205
|
+
lines.push(` ${dim("press")} ${bold(white("Ctrl+C"))} ${dim("to stop")}`);
|
|
206
|
+
lines.push("");
|
|
207
|
+
|
|
208
|
+
process.stdout.write(`${lines.join("\n")}\n`);
|
|
209
|
+
};
|
|
210
|
+
|
|
211
|
+
/**
|
|
212
|
+
* Prints a compact cluster master banner (no routes, just overview).
|
|
213
|
+
*/
|
|
214
|
+
export const printClusterBanner = (workerCount: number, cpuCount: number): void => {
|
|
215
|
+
const lines: string[] = [];
|
|
216
|
+
|
|
217
|
+
lines.push("");
|
|
218
|
+
lines.push(logo());
|
|
219
|
+
lines.push("");
|
|
220
|
+
lines.push(
|
|
221
|
+
` ${bgMagenta("CLUSTER")} ${dim("spawning")} ${bold(yellow(String(workerCount)))} ${dim("workers on")} ${bold(white(String(cpuCount)))} ${dim("CPU cores")}`,
|
|
222
|
+
);
|
|
223
|
+
lines.push("");
|
|
224
|
+
lines.push(` ${gray("├─")} ${dim("Master PID")} ${white(String(process.pid))}`);
|
|
225
|
+
lines.push(` ${gray("├─")} ${dim("Runtime")} ${magenta(`Bun ${Bun.version}`)}`);
|
|
226
|
+
lines.push(
|
|
227
|
+
` ${gray("└─")} ${dim("SO_REUSEPORT")} ${green("enabled")} ${dim("(kernel load balancing)")}`,
|
|
228
|
+
);
|
|
229
|
+
lines.push("");
|
|
230
|
+
lines.push(` ${dim("press")} ${bold(white("Ctrl+C"))} ${dim("to stop all workers")}`);
|
|
231
|
+
lines.push("");
|
|
232
|
+
|
|
233
|
+
process.stdout.write(`${lines.join("\n")}\n`);
|
|
234
|
+
};
|
|
235
|
+
|
|
236
|
+
/**
|
|
237
|
+
* Prints a compact worker ready line (one line per worker).
|
|
238
|
+
*/
|
|
239
|
+
export const printWorkerReady = (workerId: number, pid: number, port: number): void => {
|
|
240
|
+
process.stdout.write(
|
|
241
|
+
` ${green("✓")} ${dim("Worker")} ${bold(white(`#${workerId}`))} ${dim("ready")} ${gray(`(PID ${pid}, port ${port})`)}\n`,
|
|
242
|
+
);
|
|
243
|
+
};
|
|
244
|
+
|
|
245
|
+
/**
|
|
246
|
+
* Prints a clean shutdown message.
|
|
247
|
+
*/
|
|
248
|
+
export const printShutdown = (signal: string): void => {
|
|
249
|
+
process.stdout.write(
|
|
250
|
+
`\n ${yellow("⏻")} ${dim("Received")} ${bold(white(signal))}${dim(", shutting down gracefully…")}\n\n`,
|
|
251
|
+
);
|
|
252
|
+
};
|
|
253
|
+
|
|
254
|
+
/**
|
|
255
|
+
* Prints a beautiful config validation error with hints.
|
|
256
|
+
*/
|
|
257
|
+
export const printConfigError = (errors: Record<string, string[]>): void => {
|
|
258
|
+
const lines: string[] = [];
|
|
259
|
+
|
|
260
|
+
lines.push("");
|
|
261
|
+
lines.push(` ${bgMagenta("CONFIG ERROR")} ${dim("Invalid configuration detected")}`);
|
|
262
|
+
lines.push("");
|
|
263
|
+
|
|
264
|
+
for (const [field, messages] of Object.entries(errors)) {
|
|
265
|
+
for (const msg of messages) {
|
|
266
|
+
lines.push(` ${red("✗")} ${bold(white(field))} ${dim("→")} ${red(msg)}`);
|
|
267
|
+
}
|
|
268
|
+
}
|
|
269
|
+
|
|
270
|
+
lines.push("");
|
|
271
|
+
lines.push(` ${dim("Hint: Copy .env.example to .env and set the required values:")}`);
|
|
272
|
+
lines.push(` ${cyan("$ cp .env.example .env")}`);
|
|
273
|
+
lines.push("");
|
|
274
|
+
|
|
275
|
+
process.stderr.write(`${lines.join("\n")}\n`);
|
|
276
|
+
};
|
|
277
|
+
|
|
278
|
+
// ── Utilities ───────────────────────────────────────────────────────────
|
|
279
|
+
|
|
280
|
+
const getLocalIp = (): string => {
|
|
281
|
+
try {
|
|
282
|
+
const nets = require("node:os").networkInterfaces();
|
|
283
|
+
for (const name of Object.keys(nets)) {
|
|
284
|
+
for (const net of nets[name] ?? []) {
|
|
285
|
+
if (net.family === "IPv4" && !net.internal) {
|
|
286
|
+
return net.address as string;
|
|
287
|
+
}
|
|
288
|
+
}
|
|
289
|
+
}
|
|
290
|
+
} catch {
|
|
291
|
+
// ignore
|
|
292
|
+
}
|
|
293
|
+
return "0.0.0.0";
|
|
294
|
+
};
|
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Minimal compile-time–safe DI container.
|
|
3
|
+
* No decorators, no reflect-metadata, no runtime magic.
|
|
4
|
+
* Register singletons by token; resolve by token.
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
const registry = new Map<symbol, unknown>();
|
|
8
|
+
|
|
9
|
+
export const Container = {
|
|
10
|
+
register<T>(token: symbol, instance: T): void {
|
|
11
|
+
if (registry.has(token)) {
|
|
12
|
+
throw new Error(`[Container] Token already registered: ${token.toString()}`);
|
|
13
|
+
}
|
|
14
|
+
registry.set(token, instance);
|
|
15
|
+
},
|
|
16
|
+
|
|
17
|
+
resolve<T>(token: symbol): T {
|
|
18
|
+
const instance = registry.get(token);
|
|
19
|
+
if (instance === undefined) {
|
|
20
|
+
throw new Error(`[Container] Token not registered: ${token.toString()}`);
|
|
21
|
+
}
|
|
22
|
+
return instance as T;
|
|
23
|
+
},
|
|
24
|
+
|
|
25
|
+
/** Only for tests — clears the entire registry */
|
|
26
|
+
reset(): void {
|
|
27
|
+
registry.clear();
|
|
28
|
+
},
|
|
29
|
+
} as const;
|
|
30
|
+
|
|
31
|
+
/** Well-known DI tokens */
|
|
32
|
+
export const Tokens = {
|
|
33
|
+
Logger: Symbol.for("Logger"),
|
|
34
|
+
Config: Symbol.for("Config"),
|
|
35
|
+
UserRepository: Symbol.for("UserRepository"),
|
|
36
|
+
PasswordHasher: Symbol.for("PasswordHasher"),
|
|
37
|
+
TokenService: Symbol.for("TokenService"),
|
|
38
|
+
TokenBlacklist: Symbol.for("TokenBlacklist"),
|
|
39
|
+
AccountLockout: Symbol.for("AccountLockout"),
|
|
40
|
+
AuthService: Symbol.for("AuthService"),
|
|
41
|
+
UserService: Symbol.for("UserService"),
|
|
42
|
+
HealthService: Symbol.for("HealthService"),
|
|
43
|
+
AdminService: Symbol.for("AdminService"),
|
|
44
|
+
AuditLog: Symbol.for("AuditLog"),
|
|
45
|
+
Database: Symbol.for("Database"),
|
|
46
|
+
MetricsCollector: Symbol.for("MetricsCollector"),
|
|
47
|
+
AlertSink: Symbol.for("AlertSink"),
|
|
48
|
+
VerificationTokenRepository: Symbol.for("VerificationTokenRepository"),
|
|
49
|
+
RefreshTokenStore: Symbol.for("RefreshTokenStore"),
|
|
50
|
+
ApiKeyRepository: Symbol.for("ApiKeyRepository"),
|
|
51
|
+
PasswordHistory: Symbol.for("PasswordHistory"),
|
|
52
|
+
PasswordPolicy: Symbol.for("PasswordPolicy"),
|
|
53
|
+
TotpService: Symbol.for("TotpService"),
|
|
54
|
+
OAuthProviders: Symbol.for("OAuthProviders"),
|
|
55
|
+
OAuthAccountRepository: Symbol.for("OAuthAccountRepository"),
|
|
56
|
+
ApiKeyService: Symbol.for("ApiKeyService"),
|
|
57
|
+
EventBus: Symbol.for("EventBus"),
|
|
58
|
+
EventFactory: Symbol.for("EventFactory"),
|
|
59
|
+
WebhookRegistry: Symbol.for("WebhookRegistry"),
|
|
60
|
+
WebhookDispatcher: Symbol.for("WebhookDispatcher"),
|
|
61
|
+
JobQueue: Symbol.for("JobQueue"),
|
|
62
|
+
WebSocketManager: Symbol.for("WebSocketManager"),
|
|
63
|
+
SseHandler: Symbol.for("SseHandler"),
|
|
64
|
+
Cache: Symbol.for("Cache"),
|
|
65
|
+
} as const;
|
|
@@ -0,0 +1,148 @@
|
|
|
1
|
+
import type { LogLevel } from "../core/ports/logger.js";
|
|
2
|
+
|
|
3
|
+
// ── ANSI escape sequences ──────────────────────────────────────────────
|
|
4
|
+
|
|
5
|
+
const esc = (code: string) => `\x1b[${code}m`;
|
|
6
|
+
const reset = esc("0");
|
|
7
|
+
|
|
8
|
+
const bold = (s: string) => `${esc("1")}${s}${reset}`;
|
|
9
|
+
const dim = (s: string) => `${esc("2")}${s}${reset}`;
|
|
10
|
+
|
|
11
|
+
const cyan = (s: string) => `${esc("36")}${s}${reset}`;
|
|
12
|
+
const green = (s: string) => `${esc("32")}${s}${reset}`;
|
|
13
|
+
const yellow = (s: string) => `${esc("33")}${s}${reset}`;
|
|
14
|
+
const red = (s: string) => `${esc("31")}${s}${reset}`;
|
|
15
|
+
const gray = (s: string) => `${esc("90")}${s}${reset}`;
|
|
16
|
+
const white = (s: string) => `${esc("97")}${s}${reset}`;
|
|
17
|
+
|
|
18
|
+
const bgGreen = (s: string) => `${esc("42")}${esc("30")} ${s} ${reset}`;
|
|
19
|
+
const bgCyan = (s: string) => `${esc("46")}${esc("30")} ${s} ${reset}`;
|
|
20
|
+
const bgYellow = (s: string) => `${esc("43")}${esc("30")} ${s} ${reset}`;
|
|
21
|
+
const bgRed = (s: string) => `${esc("41")}${esc("97")} ${s} ${reset}`;
|
|
22
|
+
|
|
23
|
+
// ── Helpers ─────────────────────────────────────────────────────────────
|
|
24
|
+
|
|
25
|
+
const timestamp = (): string => {
|
|
26
|
+
const d = new Date();
|
|
27
|
+
const h = String(d.getHours()).padStart(2, "0");
|
|
28
|
+
const m = String(d.getMinutes()).padStart(2, "0");
|
|
29
|
+
const s = String(d.getSeconds()).padStart(2, "0");
|
|
30
|
+
const ms = String(d.getMilliseconds()).padStart(3, "0");
|
|
31
|
+
return `${h}:${m}:${s}.${ms}`;
|
|
32
|
+
};
|
|
33
|
+
|
|
34
|
+
const levelBadge = (level: LogLevel): string => {
|
|
35
|
+
switch (level) {
|
|
36
|
+
case "debug":
|
|
37
|
+
return gray("DBG");
|
|
38
|
+
case "info":
|
|
39
|
+
return green("INF");
|
|
40
|
+
case "warn":
|
|
41
|
+
return yellow("WRN");
|
|
42
|
+
case "error":
|
|
43
|
+
return red("ERR");
|
|
44
|
+
case "fatal":
|
|
45
|
+
return bgRed("FTL");
|
|
46
|
+
}
|
|
47
|
+
};
|
|
48
|
+
|
|
49
|
+
const methodBadge = (method: string): string => {
|
|
50
|
+
switch (method) {
|
|
51
|
+
case "GET":
|
|
52
|
+
return bgGreen("GET");
|
|
53
|
+
case "POST":
|
|
54
|
+
return bgCyan("POST");
|
|
55
|
+
case "PATCH":
|
|
56
|
+
return bgYellow("PATCH");
|
|
57
|
+
case "PUT":
|
|
58
|
+
return bgYellow("PUT");
|
|
59
|
+
case "DELETE":
|
|
60
|
+
return bgRed("DEL");
|
|
61
|
+
case "OPTIONS":
|
|
62
|
+
return gray("OPT");
|
|
63
|
+
default:
|
|
64
|
+
return white(method);
|
|
65
|
+
}
|
|
66
|
+
};
|
|
67
|
+
|
|
68
|
+
const statusColor = (status: number): string => {
|
|
69
|
+
if (status < 300) return bold(green(String(status)));
|
|
70
|
+
if (status < 400) return bold(cyan(String(status)));
|
|
71
|
+
if (status < 500) return bold(yellow(String(status)));
|
|
72
|
+
return bold(red(String(status)));
|
|
73
|
+
};
|
|
74
|
+
|
|
75
|
+
const durationColor = (ms: number): string => {
|
|
76
|
+
if (ms < 1) return green(`${ms}ms`);
|
|
77
|
+
if (ms < 50) return green(`${ms}ms`);
|
|
78
|
+
if (ms < 200) return yellow(`${ms}ms`);
|
|
79
|
+
return red(`${ms}ms`);
|
|
80
|
+
};
|
|
81
|
+
|
|
82
|
+
const formatMeta = (meta: Record<string, unknown>): string => {
|
|
83
|
+
const entries = Object.entries(meta);
|
|
84
|
+
if (entries.length === 0) return "";
|
|
85
|
+
const parts = entries.map(([k, v]) => `${dim(k)}${dim("=")}${white(String(v))}`);
|
|
86
|
+
return ` ${parts.join(" ")}`;
|
|
87
|
+
};
|
|
88
|
+
|
|
89
|
+
// ── Public formatters ───────────────────────────────────────────────────
|
|
90
|
+
|
|
91
|
+
/**
|
|
92
|
+
* Format a structured log entry (used by the Logger port).
|
|
93
|
+
*
|
|
94
|
+
* INF 12:34:56.789 Registering user service=auth email=foo@bar.com
|
|
95
|
+
*/
|
|
96
|
+
export const formatLogEntry = (
|
|
97
|
+
level: LogLevel,
|
|
98
|
+
msg: string,
|
|
99
|
+
meta: Record<string, unknown>,
|
|
100
|
+
): string => {
|
|
101
|
+
const ts = dim(gray(timestamp()));
|
|
102
|
+
const badge = levelBadge(level);
|
|
103
|
+
const metaStr = formatMeta(meta);
|
|
104
|
+
return ` ${badge} ${ts} ${white(msg)}${metaStr}\n`;
|
|
105
|
+
};
|
|
106
|
+
|
|
107
|
+
/**
|
|
108
|
+
* Format an HTTP access log line (used by batched server logger).
|
|
109
|
+
*
|
|
110
|
+
* ← GET 200 /health 0.34ms ip=127.0.0.1 rid=abc123
|
|
111
|
+
*/
|
|
112
|
+
export const formatAccessLog = (
|
|
113
|
+
method: string,
|
|
114
|
+
path: string,
|
|
115
|
+
status: number,
|
|
116
|
+
durationMs: number,
|
|
117
|
+
ip: string,
|
|
118
|
+
requestId: string,
|
|
119
|
+
): string => {
|
|
120
|
+
const ts = dim(gray(timestamp()));
|
|
121
|
+
const arrow = dim("←");
|
|
122
|
+
const mBadge = methodBadge(method);
|
|
123
|
+
const st = statusColor(status);
|
|
124
|
+
const dur = durationColor(durationMs);
|
|
125
|
+
const p = white(path);
|
|
126
|
+
const meta = dim(gray(`ip=${ip} rid=${requestId.slice(0, 8)}`));
|
|
127
|
+
return ` ${arrow} ${ts} ${mBadge} ${st} ${p} ${dur} ${meta}\n`;
|
|
128
|
+
};
|
|
129
|
+
|
|
130
|
+
/**
|
|
131
|
+
* Format a rate-limit warning line.
|
|
132
|
+
*
|
|
133
|
+
* ⚠ 12:34:56.789 Rate limited ip=1.2.3.4 hits=152
|
|
134
|
+
*/
|
|
135
|
+
export const formatRateLimitLog = (ip: string, count: number): string => {
|
|
136
|
+
const ts = dim(gray(timestamp()));
|
|
137
|
+
return ` ${yellow("⚠")} ${ts} ${bold(yellow("Rate limited"))} ${dim("ip=")}${white(ip)} ${dim("hits=")}${white(String(count))}\n`;
|
|
138
|
+
};
|
|
139
|
+
|
|
140
|
+
/**
|
|
141
|
+
* Format a CORS rejection line.
|
|
142
|
+
*
|
|
143
|
+
* ✗ 12:34:56.789 CORS rejected origin=evil.com
|
|
144
|
+
*/
|
|
145
|
+
export const formatCorsRejectLog = (origin: string): string => {
|
|
146
|
+
const ts = dim(gray(timestamp()));
|
|
147
|
+
return ` ${red("✗")} ${ts} ${bold(red("CORS rejected"))} ${dim("origin=")}${white(origin)}\n`;
|
|
148
|
+
};
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Tiny constant-time string comparison to prevent timing attacks on tokens / secrets.
|
|
3
|
+
*/
|
|
4
|
+
export const timingSafeEqual = (a: string, b: string): boolean => {
|
|
5
|
+
const enc = new TextEncoder();
|
|
6
|
+
const bufA = enc.encode(a);
|
|
7
|
+
const bufB = enc.encode(b);
|
|
8
|
+
if (bufA.byteLength !== bufB.byteLength) return false;
|
|
9
|
+
|
|
10
|
+
return crypto.subtle
|
|
11
|
+
? // Bun exposes crypto.subtle – use it
|
|
12
|
+
(() => {
|
|
13
|
+
let mismatch = 0;
|
|
14
|
+
for (let i = 0; i < bufA.byteLength; i++) {
|
|
15
|
+
mismatch |= (bufA[i] ?? 0) ^ (bufB[i] ?? 0);
|
|
16
|
+
}
|
|
17
|
+
return mismatch === 0;
|
|
18
|
+
})()
|
|
19
|
+
: false;
|
|
20
|
+
};
|