@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
package/src/cli/ui.ts
ADDED
|
@@ -0,0 +1,189 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* CLI UI utilities — zero-dependency ANSI colors, spinner, and prompts.
|
|
3
|
+
* Follows the same style as the server startup banner.
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
// ── ANSI escape sequences ──────────────────────────────────────────────
|
|
7
|
+
|
|
8
|
+
const esc = (code: string) => `\x1b[${code}m`;
|
|
9
|
+
const reset = esc("0");
|
|
10
|
+
|
|
11
|
+
export const bold = (s: string) => `${esc("1")}${s}${reset}`;
|
|
12
|
+
export const dim = (s: string) => `${esc("2")}${s}${reset}`;
|
|
13
|
+
|
|
14
|
+
export const cyan = (s: string) => `${esc("36")}${s}${reset}`;
|
|
15
|
+
export const green = (s: string) => `${esc("32")}${s}${reset}`;
|
|
16
|
+
export const yellow = (s: string) => `${esc("33")}${s}${reset}`;
|
|
17
|
+
export const magenta = (s: string) => `${esc("35")}${s}${reset}`;
|
|
18
|
+
export const blue = (s: string) => `${esc("34")}${s}${reset}`;
|
|
19
|
+
export const red = (s: string) => `${esc("31")}${s}${reset}`;
|
|
20
|
+
export const gray = (s: string) => `${esc("90")}${s}${reset}`;
|
|
21
|
+
export const white = (s: string) => `${esc("97")}${s}${reset}`;
|
|
22
|
+
|
|
23
|
+
export const bgCyan = (s: string) => `${esc("46")}${esc("30")} ${s} ${reset}`;
|
|
24
|
+
export const bgGreen = (s: string) => `${esc("42")}${esc("30")} ${s} ${reset}`;
|
|
25
|
+
export const bgYellow = (s: string) => `${esc("43")}${esc("30")} ${s} ${reset}`;
|
|
26
|
+
export const bgMagenta = (s: string) => `${esc("45")}${esc("97")} ${s} ${reset}`;
|
|
27
|
+
export const bgRed = (s: string) => `${esc("41")}${esc("97")} ${s} ${reset}`;
|
|
28
|
+
|
|
29
|
+
// ── Icons ───────────────────────────────────────────────────────────────
|
|
30
|
+
|
|
31
|
+
export const icons = {
|
|
32
|
+
success: green("✔"),
|
|
33
|
+
error: red("✗"),
|
|
34
|
+
warning: yellow("⚠"),
|
|
35
|
+
info: cyan("ℹ"),
|
|
36
|
+
arrow: cyan("→"),
|
|
37
|
+
chevron: cyan("›"),
|
|
38
|
+
sparkle: magenta("✦"),
|
|
39
|
+
bolt: yellow("⚡"),
|
|
40
|
+
folder: blue("📁"),
|
|
41
|
+
file: gray("📄"),
|
|
42
|
+
gear: gray("⚙"),
|
|
43
|
+
rocket: magenta("🚀"),
|
|
44
|
+
package: cyan("📦"),
|
|
45
|
+
} as const;
|
|
46
|
+
|
|
47
|
+
// ── Logo ────────────────────────────────────────────────────────────────
|
|
48
|
+
|
|
49
|
+
export const logo = (version: string): string => {
|
|
50
|
+
const lines = [
|
|
51
|
+
`${bold(cyan(" ┌─────────────────────────────────────────┐"))}`,
|
|
52
|
+
`${bold(cyan(" │"))} ${bold(cyan("│"))}`,
|
|
53
|
+
`${bold(cyan(" │"))} ${bold(white("⚡ onlyApi CLI"))} ${dim(gray(`v${version}`))} ${bold(cyan("│"))}`,
|
|
54
|
+
`${bold(cyan(" │"))} ${dim(gray("Zero-dep enterprise REST API on Bun"))} ${bold(cyan("│"))}`,
|
|
55
|
+
`${bold(cyan(" │"))} ${bold(cyan("│"))}`,
|
|
56
|
+
`${bold(cyan(" └─────────────────────────────────────────┘"))}`,
|
|
57
|
+
];
|
|
58
|
+
return lines.join("\n");
|
|
59
|
+
};
|
|
60
|
+
|
|
61
|
+
// ── Output helpers ──────────────────────────────────────────────────────
|
|
62
|
+
|
|
63
|
+
export const log = (msg: string) => process.stdout.write(`${msg}\n`);
|
|
64
|
+
export const blank = () => process.stdout.write("\n");
|
|
65
|
+
export const error = (msg: string) => process.stderr.write(` ${icons.error} ${red(msg)}\n`);
|
|
66
|
+
export const warn = (msg: string) => process.stdout.write(` ${icons.warning} ${yellow(msg)}\n`);
|
|
67
|
+
export const info = (msg: string) => process.stdout.write(` ${icons.info} ${msg}\n`);
|
|
68
|
+
export const success = (msg: string) => process.stdout.write(` ${icons.success} ${green(msg)}\n`);
|
|
69
|
+
export const step = (msg: string) => process.stdout.write(` ${icons.chevron} ${msg}\n`);
|
|
70
|
+
|
|
71
|
+
// ── Spinner ─────────────────────────────────────────────────────────────
|
|
72
|
+
|
|
73
|
+
const spinnerFrames = ["⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "⠧", "⠇", "⠏"] as const;
|
|
74
|
+
|
|
75
|
+
export interface Spinner {
|
|
76
|
+
start: () => void;
|
|
77
|
+
stop: (finalMsg?: string) => void;
|
|
78
|
+
update: (msg: string) => void;
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
export const createSpinner = (message: string): Spinner => {
|
|
82
|
+
let frame = 0;
|
|
83
|
+
let timer: ReturnType<typeof setInterval> | null = null;
|
|
84
|
+
let currentMsg = message;
|
|
85
|
+
|
|
86
|
+
const clear = () => {
|
|
87
|
+
process.stdout.write("\r\x1b[K");
|
|
88
|
+
};
|
|
89
|
+
|
|
90
|
+
return {
|
|
91
|
+
start() {
|
|
92
|
+
timer = setInterval(() => {
|
|
93
|
+
clear();
|
|
94
|
+
const icon = spinnerFrames[frame % spinnerFrames.length] ?? "⠋";
|
|
95
|
+
process.stdout.write(` ${cyan(icon)} ${currentMsg}`);
|
|
96
|
+
frame++;
|
|
97
|
+
}, 80);
|
|
98
|
+
},
|
|
99
|
+
update(msg: string) {
|
|
100
|
+
currentMsg = msg;
|
|
101
|
+
},
|
|
102
|
+
stop(finalMsg?: string) {
|
|
103
|
+
if (timer) clearInterval(timer);
|
|
104
|
+
clear();
|
|
105
|
+
if (finalMsg) {
|
|
106
|
+
process.stdout.write(` ${icons.success} ${green(finalMsg)}\n`);
|
|
107
|
+
}
|
|
108
|
+
},
|
|
109
|
+
};
|
|
110
|
+
};
|
|
111
|
+
|
|
112
|
+
// ── Prompt (simple stdin reader) ────────────────────────────────────────
|
|
113
|
+
|
|
114
|
+
export const prompt = async (question: string, defaultValue?: string): Promise<string> => {
|
|
115
|
+
const suffix = defaultValue ? ` ${dim(`(${defaultValue})`)}` : "";
|
|
116
|
+
process.stdout.write(` ${icons.chevron} ${question}${suffix}: `);
|
|
117
|
+
|
|
118
|
+
const reader = Bun.stdin.stream().getReader();
|
|
119
|
+
const { value } = await reader.read();
|
|
120
|
+
reader.releaseLock();
|
|
121
|
+
|
|
122
|
+
const input = value ? new TextDecoder().decode(value).trim() : "";
|
|
123
|
+
return input || defaultValue || "";
|
|
124
|
+
};
|
|
125
|
+
|
|
126
|
+
export const confirm = async (question: string, defaultYes = true): Promise<boolean> => {
|
|
127
|
+
const hint = defaultYes ? `${bold("Y")}/n` : `y/${bold("N")}`;
|
|
128
|
+
process.stdout.write(` ${icons.chevron} ${question} ${dim(`[${hint}]`)}: `);
|
|
129
|
+
|
|
130
|
+
const reader = Bun.stdin.stream().getReader();
|
|
131
|
+
const { value } = await reader.read();
|
|
132
|
+
reader.releaseLock();
|
|
133
|
+
|
|
134
|
+
const input = value ? new TextDecoder().decode(value).trim().toLowerCase() : "";
|
|
135
|
+
if (input === "") return defaultYes;
|
|
136
|
+
return input === "y" || input === "yes";
|
|
137
|
+
};
|
|
138
|
+
|
|
139
|
+
// ── Table helper ────────────────────────────────────────────────────────
|
|
140
|
+
|
|
141
|
+
export const printKeyValue = (pairs: readonly [string, string][]): void => {
|
|
142
|
+
const maxKey = Math.max(...pairs.map(([k]) => k.length));
|
|
143
|
+
for (const [key, value] of pairs) {
|
|
144
|
+
log(` ${gray("│")} ${dim(key.padEnd(maxKey))} ${white(value)}`);
|
|
145
|
+
}
|
|
146
|
+
};
|
|
147
|
+
|
|
148
|
+
// ── Section header ──────────────────────────────────────────────────────
|
|
149
|
+
|
|
150
|
+
export const section = (title: string): void => {
|
|
151
|
+
blank();
|
|
152
|
+
log(` ${bold(white(title))}`);
|
|
153
|
+
log(` ${gray("─".repeat(50))}`);
|
|
154
|
+
};
|
|
155
|
+
|
|
156
|
+
// ── Box ─────────────────────────────────────────────────────────────────
|
|
157
|
+
|
|
158
|
+
export const box = (lines: string[]): void => {
|
|
159
|
+
const maxLen = Math.max(...lines.map((l) => stripAnsi(l).length));
|
|
160
|
+
const border = gray("─".repeat(maxLen + 4));
|
|
161
|
+
|
|
162
|
+
log(` ┌${border}┐`);
|
|
163
|
+
for (const line of lines) {
|
|
164
|
+
const padding = " ".repeat(maxLen - stripAnsi(line).length);
|
|
165
|
+
log(` │ ${line}${padding} │`);
|
|
166
|
+
}
|
|
167
|
+
log(` └${border}┘`);
|
|
168
|
+
};
|
|
169
|
+
|
|
170
|
+
// ── Strip ANSI codes for length calculation ─────────────────────────────
|
|
171
|
+
|
|
172
|
+
// biome-ignore lint/suspicious/noControlCharactersInRegex: intentional ANSI escape stripping
|
|
173
|
+
const stripAnsi = (s: string): string => s.replace(/\x1b\[[0-9;]*m/g, "");
|
|
174
|
+
|
|
175
|
+
// ── Duration formatter ─────────────────────────────────────────────────
|
|
176
|
+
|
|
177
|
+
export const formatDuration = (ms: number): string => {
|
|
178
|
+
if (ms < 1000) return `${Math.round(ms)}ms`;
|
|
179
|
+
if (ms < 60_000) return `${(ms / 1000).toFixed(1)}s`;
|
|
180
|
+
return `${Math.floor(ms / 60_000)}m ${Math.round((ms % 60_000) / 1000)}s`;
|
|
181
|
+
};
|
|
182
|
+
|
|
183
|
+
// ── Random string generator ────────────────────────────────────────────
|
|
184
|
+
|
|
185
|
+
export const randomSecret = (length = 64): string => {
|
|
186
|
+
const chars = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789";
|
|
187
|
+
const bytes = crypto.getRandomValues(new Uint8Array(length));
|
|
188
|
+
return Array.from(bytes, (b) => chars[b % chars.length]).join("");
|
|
189
|
+
};
|
package/src/cluster.ts
ADDED
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
import { cpus } from "node:os";
|
|
2
|
+
import { printClusterBanner, printShutdown } from "./shared/cli.js";
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* Clustered entry point — spawns one worker per CPU core.
|
|
6
|
+
*
|
|
7
|
+
* Usage: NODE_ENV=production bun src/cluster.ts
|
|
8
|
+
*
|
|
9
|
+
* Each worker runs its own Bun.serve() with reusePort: true,
|
|
10
|
+
* allowing the kernel to load-balance across all cores via SO_REUSEPORT.
|
|
11
|
+
* This is how you scale to millions of req/s on multi-core servers.
|
|
12
|
+
*/
|
|
13
|
+
|
|
14
|
+
const cpuCount = cpus().length;
|
|
15
|
+
const numWorkers = Number(Bun.env["WORKERS"] ?? cpuCount);
|
|
16
|
+
|
|
17
|
+
printClusterBanner(numWorkers, cpuCount);
|
|
18
|
+
|
|
19
|
+
const workers: Array<ReturnType<typeof Bun.spawn>> = [];
|
|
20
|
+
|
|
21
|
+
for (let i = 0; i < numWorkers; i++) {
|
|
22
|
+
const worker = Bun.spawn(["bun", "src/main.ts"], {
|
|
23
|
+
env: {
|
|
24
|
+
...process.env,
|
|
25
|
+
WORKER_ID: String(i),
|
|
26
|
+
},
|
|
27
|
+
stdout: "inherit",
|
|
28
|
+
stderr: "inherit",
|
|
29
|
+
});
|
|
30
|
+
workers.push(worker);
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
// Forward SIGINT/SIGTERM to all workers for graceful shutdown
|
|
34
|
+
const shutdown = (signal: string) => {
|
|
35
|
+
printShutdown(signal);
|
|
36
|
+
for (const w of workers) {
|
|
37
|
+
w.kill();
|
|
38
|
+
}
|
|
39
|
+
process.exit(0);
|
|
40
|
+
};
|
|
41
|
+
|
|
42
|
+
process.on("SIGINT", () => shutdown("SIGINT"));
|
|
43
|
+
process.on("SIGTERM", () => shutdown("SIGTERM"));
|
|
44
|
+
|
|
45
|
+
// Monitor worker health
|
|
46
|
+
const dim = (s: string) => `\x1b[2m${s}\x1b[0m`;
|
|
47
|
+
const yellow = (s: string) => `\x1b[33m${s}\x1b[0m`;
|
|
48
|
+
const green = (s: string) => `\x1b[32m${s}\x1b[0m`;
|
|
49
|
+
const white = (s: string) => `\x1b[97m${s}\x1b[0m`;
|
|
50
|
+
const bold = (s: string) => `\x1b[1m${s}\x1b[0m`;
|
|
51
|
+
|
|
52
|
+
for (const [i, worker] of workers.entries()) {
|
|
53
|
+
// Log when worker becomes ready
|
|
54
|
+
process.stdout.write(` ${green("✓")} ${dim("Worker")} ${bold(white(`#${i}`))} ${dim("ready")} ${dim(`(PID ${worker.pid})`)}
|
|
55
|
+
`);
|
|
56
|
+
|
|
57
|
+
worker.exited.then((code) => {
|
|
58
|
+
const icon = code === 0 ? green("●") : yellow("●");
|
|
59
|
+
process.stdout.write(` ${icon} ${dim("Worker")} ${bold(white(`#${i}`))} ${dim("exited")} ${dim(`(code ${code})`)}
|
|
60
|
+
`);
|
|
61
|
+
});
|
|
62
|
+
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export { type User, type UserRole, UserRole as UserRoles } from "./user.entity.js";
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
import type { Timestamp, UserId } from "../types/index.js";
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* User entity — pure data, no behaviour, no framework deps.
|
|
5
|
+
*/
|
|
6
|
+
export interface User {
|
|
7
|
+
readonly id: UserId;
|
|
8
|
+
readonly email: string;
|
|
9
|
+
readonly passwordHash: string;
|
|
10
|
+
readonly role: UserRole;
|
|
11
|
+
readonly emailVerified: boolean;
|
|
12
|
+
readonly mfaEnabled: boolean;
|
|
13
|
+
readonly mfaSecret: string | null;
|
|
14
|
+
readonly passwordChangedAt: Timestamp | null;
|
|
15
|
+
readonly createdAt: Timestamp;
|
|
16
|
+
readonly updatedAt: Timestamp;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
export const UserRole = {
|
|
20
|
+
ADMIN: "admin",
|
|
21
|
+
USER: "user",
|
|
22
|
+
} as const;
|
|
23
|
+
|
|
24
|
+
export type UserRole = (typeof UserRole)[keyof typeof UserRole];
|
|
@@ -0,0 +1,81 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Canonical application error — every failure in the system is expressed
|
|
3
|
+
* as an AppError so HTTP, logging, and metrics layers have a single shape.
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
export const ErrorCode = {
|
|
7
|
+
// Client errors
|
|
8
|
+
BAD_REQUEST: "BAD_REQUEST",
|
|
9
|
+
UNAUTHORIZED: "UNAUTHORIZED",
|
|
10
|
+
FORBIDDEN: "FORBIDDEN",
|
|
11
|
+
NOT_FOUND: "NOT_FOUND",
|
|
12
|
+
CONFLICT: "CONFLICT",
|
|
13
|
+
RATE_LIMITED: "RATE_LIMITED",
|
|
14
|
+
VALIDATION: "VALIDATION",
|
|
15
|
+
// Server errors
|
|
16
|
+
INTERNAL: "INTERNAL",
|
|
17
|
+
SERVICE_UNAVAILABLE: "SERVICE_UNAVAILABLE",
|
|
18
|
+
TIMEOUT: "TIMEOUT",
|
|
19
|
+
} as const;
|
|
20
|
+
|
|
21
|
+
export type ErrorCode = (typeof ErrorCode)[keyof typeof ErrorCode];
|
|
22
|
+
|
|
23
|
+
export interface AppError {
|
|
24
|
+
readonly code: ErrorCode;
|
|
25
|
+
readonly message: string;
|
|
26
|
+
readonly details?: Record<string, unknown>;
|
|
27
|
+
readonly cause?: unknown;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
const STATUS_MAP: Record<ErrorCode, number> = {
|
|
31
|
+
BAD_REQUEST: 400,
|
|
32
|
+
UNAUTHORIZED: 401,
|
|
33
|
+
FORBIDDEN: 403,
|
|
34
|
+
NOT_FOUND: 404,
|
|
35
|
+
CONFLICT: 409,
|
|
36
|
+
RATE_LIMITED: 429,
|
|
37
|
+
VALIDATION: 422,
|
|
38
|
+
INTERNAL: 500,
|
|
39
|
+
SERVICE_UNAVAILABLE: 503,
|
|
40
|
+
TIMEOUT: 504,
|
|
41
|
+
};
|
|
42
|
+
|
|
43
|
+
export const httpStatus = (code: ErrorCode): number => STATUS_MAP[code];
|
|
44
|
+
|
|
45
|
+
/** Factory helpers */
|
|
46
|
+
export const appError = (
|
|
47
|
+
code: ErrorCode,
|
|
48
|
+
message: string,
|
|
49
|
+
details?: Record<string, unknown>,
|
|
50
|
+
cause?: unknown,
|
|
51
|
+
): AppError => {
|
|
52
|
+
const error: AppError = { code, message };
|
|
53
|
+
if (details !== undefined) {
|
|
54
|
+
return { ...error, details, cause };
|
|
55
|
+
}
|
|
56
|
+
if (cause !== undefined) {
|
|
57
|
+
return { ...error, cause };
|
|
58
|
+
}
|
|
59
|
+
return error;
|
|
60
|
+
};
|
|
61
|
+
|
|
62
|
+
export const badRequest = (msg: string, details?: Record<string, unknown>): AppError =>
|
|
63
|
+
appError(ErrorCode.BAD_REQUEST, msg, details);
|
|
64
|
+
|
|
65
|
+
export const unauthorized = (msg = "Unauthorized"): AppError =>
|
|
66
|
+
appError(ErrorCode.UNAUTHORIZED, msg);
|
|
67
|
+
|
|
68
|
+
export const forbidden = (msg = "Forbidden"): AppError => appError(ErrorCode.FORBIDDEN, msg);
|
|
69
|
+
|
|
70
|
+
export const notFound = (resource: string): AppError =>
|
|
71
|
+
appError(ErrorCode.NOT_FOUND, `${resource} not found`);
|
|
72
|
+
|
|
73
|
+
export const conflict = (msg: string): AppError => appError(ErrorCode.CONFLICT, msg);
|
|
74
|
+
|
|
75
|
+
export const rateLimited = (): AppError => appError(ErrorCode.RATE_LIMITED, "Too many requests");
|
|
76
|
+
|
|
77
|
+
export const validation = (details: Record<string, unknown>): AppError =>
|
|
78
|
+
appError(ErrorCode.VALIDATION, "Validation failed", details);
|
|
79
|
+
|
|
80
|
+
export const internal = (msg = "Internal server error", cause?: unknown): AppError =>
|
|
81
|
+
appError(ErrorCode.INTERNAL, msg, undefined, cause);
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
import type { AppError } from "../errors/app-error.js";
|
|
2
|
+
import type { Result } from "../types/result.js";
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* Port: Account Lockout
|
|
6
|
+
* Tracks failed login attempts and determines if an account is locked.
|
|
7
|
+
*/
|
|
8
|
+
export interface AccountLockout {
|
|
9
|
+
/** Record a failed login attempt. Returns whether the account is now locked. */
|
|
10
|
+
recordFailedAttempt(email: string): Promise<Result<boolean, AppError>>;
|
|
11
|
+
/** Reset failed attempts on successful login */
|
|
12
|
+
resetAttempts(email: string): Promise<Result<void, AppError>>;
|
|
13
|
+
/** Check if account is currently locked. Returns lock expiry time or null. */
|
|
14
|
+
isLocked(email: string): Promise<Result<number | null, AppError>>;
|
|
15
|
+
}
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Alert sink port — webhook/notification interface for critical events.
|
|
3
|
+
*/
|
|
4
|
+
|
|
5
|
+
export const AlertLevel = {
|
|
6
|
+
WARNING: "warning",
|
|
7
|
+
CRITICAL: "critical",
|
|
8
|
+
RESOLVED: "resolved",
|
|
9
|
+
} as const;
|
|
10
|
+
|
|
11
|
+
export type AlertLevel = (typeof AlertLevel)[keyof typeof AlertLevel];
|
|
12
|
+
|
|
13
|
+
export interface AlertPayload {
|
|
14
|
+
readonly level: AlertLevel;
|
|
15
|
+
readonly title: string;
|
|
16
|
+
readonly message: string;
|
|
17
|
+
readonly timestamp: string;
|
|
18
|
+
readonly source: string;
|
|
19
|
+
readonly metadata?: Record<string, unknown>;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
export interface AlertSink {
|
|
23
|
+
/** Send an alert notification */
|
|
24
|
+
send(payload: AlertPayload): Promise<void>;
|
|
25
|
+
/** Check if the alert sink is configured and available */
|
|
26
|
+
readonly enabled: boolean;
|
|
27
|
+
}
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
import type { AppError } from "../errors/app-error.js";
|
|
2
|
+
import type { UserId } from "../types/brand.js";
|
|
3
|
+
import type { Result } from "../types/result.js";
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* Port: API Key Repository
|
|
7
|
+
* Manages hashed API keys for service-to-service authentication.
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
export interface ApiKey {
|
|
11
|
+
readonly id: string;
|
|
12
|
+
readonly userId: UserId;
|
|
13
|
+
readonly name: string;
|
|
14
|
+
readonly keyPrefix: string;
|
|
15
|
+
readonly scopes: readonly string[];
|
|
16
|
+
readonly expiresAt: number | null;
|
|
17
|
+
readonly lastUsedAt: number | null;
|
|
18
|
+
readonly createdAt: number;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
export interface ApiKeyRepository {
|
|
22
|
+
/** Create a new API key. Returns the full key (shown once). */
|
|
23
|
+
create(
|
|
24
|
+
userId: UserId,
|
|
25
|
+
name: string,
|
|
26
|
+
scopes: readonly string[],
|
|
27
|
+
expiresAt?: number,
|
|
28
|
+
): Promise<Result<{ key: ApiKey; rawKey: string }, AppError>>;
|
|
29
|
+
/** Verify a raw API key. Returns the key metadata if valid. */
|
|
30
|
+
verify(rawKey: string): Promise<Result<ApiKey, AppError>>;
|
|
31
|
+
/** List all API keys for a user (key hashes are never returned) */
|
|
32
|
+
listByUser(userId: UserId): Promise<Result<readonly ApiKey[], AppError>>;
|
|
33
|
+
/** Revoke (delete) an API key */
|
|
34
|
+
revoke(id: string, userId: UserId): Promise<Result<void, AppError>>;
|
|
35
|
+
/** Update last used timestamp */
|
|
36
|
+
touch(id: string): Promise<Result<void, AppError>>;
|
|
37
|
+
}
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
import type { AppError } from "../errors/app-error.js";
|
|
2
|
+
import type { Result } from "../types/result.js";
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* Port: Audit Log — append-only ledger of significant system events.
|
|
6
|
+
* Records who did what, when, and from which IP address.
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
export interface AuditEntry {
|
|
10
|
+
readonly id: string;
|
|
11
|
+
readonly userId: string | null;
|
|
12
|
+
readonly action: AuditAction;
|
|
13
|
+
readonly resource: string;
|
|
14
|
+
readonly resourceId: string | null;
|
|
15
|
+
readonly detail: string | null;
|
|
16
|
+
readonly ip: string;
|
|
17
|
+
readonly timestamp: number;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
export const AuditAction = {
|
|
21
|
+
USER_REGISTERED: "USER_REGISTERED",
|
|
22
|
+
USER_LOGGED_IN: "USER_LOGGED_IN",
|
|
23
|
+
USER_LOGGED_OUT: "USER_LOGGED_OUT",
|
|
24
|
+
USER_LOGIN_FAILED: "USER_LOGIN_FAILED",
|
|
25
|
+
USER_UPDATED: "USER_UPDATED",
|
|
26
|
+
USER_DELETED: "USER_DELETED",
|
|
27
|
+
USER_BANNED: "USER_BANNED",
|
|
28
|
+
USER_UNBANNED: "USER_UNBANNED",
|
|
29
|
+
USER_ROLE_CHANGED: "USER_ROLE_CHANGED",
|
|
30
|
+
TOKEN_REFRESHED: "TOKEN_REFRESHED",
|
|
31
|
+
ACCOUNT_LOCKED: "ACCOUNT_LOCKED",
|
|
32
|
+
} as const;
|
|
33
|
+
|
|
34
|
+
export type AuditAction = (typeof AuditAction)[keyof typeof AuditAction];
|
|
35
|
+
|
|
36
|
+
export interface AuditLog {
|
|
37
|
+
append(entry: Omit<AuditEntry, "id" | "timestamp">): Promise<Result<void, AppError>>;
|
|
38
|
+
query(options: AuditQueryOptions): Promise<Result<readonly AuditEntry[], AppError>>;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
export interface AuditQueryOptions {
|
|
42
|
+
readonly userId?: string | undefined;
|
|
43
|
+
readonly action?: AuditAction | undefined;
|
|
44
|
+
readonly limit?: number | undefined;
|
|
45
|
+
readonly since?: number | undefined;
|
|
46
|
+
}
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Port: Cache — generic key-value cache abstraction.
|
|
3
|
+
* Implementations: in-memory (Map), Redis.
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
import type { AppError } from "../errors/app-error.js";
|
|
7
|
+
import type { Result } from "../types/result.js";
|
|
8
|
+
|
|
9
|
+
export interface Cache {
|
|
10
|
+
/** Get a value by key. Returns null if not found or expired. */
|
|
11
|
+
get<T = unknown>(key: string): Promise<Result<T | null, AppError>>;
|
|
12
|
+
/** Set a value with optional TTL in milliseconds. */
|
|
13
|
+
set<T = unknown>(key: string, value: T, ttlMs?: number): Promise<Result<void, AppError>>;
|
|
14
|
+
/** Delete a key. Returns true if it existed. */
|
|
15
|
+
del(key: string): Promise<Result<boolean, AppError>>;
|
|
16
|
+
/** Check if a key exists. */
|
|
17
|
+
has(key: string): Promise<Result<boolean, AppError>>;
|
|
18
|
+
/** Increment a numeric value by delta (default 1). Creates with value=delta if key absent. */
|
|
19
|
+
incr(key: string, delta?: number): Promise<Result<number, AppError>>;
|
|
20
|
+
/** Delete all keys matching a glob pattern (e.g. "rate:*"). */
|
|
21
|
+
delPattern(pattern: string): Promise<Result<number, AppError>>;
|
|
22
|
+
/** Close the connection / release resources. */
|
|
23
|
+
close(): Promise<void>;
|
|
24
|
+
}
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Circuit breaker port — resilience pattern for external service calls.
|
|
3
|
+
*
|
|
4
|
+
* States:
|
|
5
|
+
* CLOSED → normal operation; failures counted
|
|
6
|
+
* OPEN → requests short-circuited; waiting for reset timeout
|
|
7
|
+
* HALF_OPEN → limited probes allowed to test recovery
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
export const CircuitState = {
|
|
11
|
+
CLOSED: "CLOSED",
|
|
12
|
+
OPEN: "OPEN",
|
|
13
|
+
HALF_OPEN: "HALF_OPEN",
|
|
14
|
+
} as const;
|
|
15
|
+
|
|
16
|
+
export type CircuitState = (typeof CircuitState)[keyof typeof CircuitState];
|
|
17
|
+
|
|
18
|
+
export interface CircuitBreakerOptions {
|
|
19
|
+
/** Name for identification and metrics */
|
|
20
|
+
readonly name: string;
|
|
21
|
+
/** Number of failures before opening (default: 5) */
|
|
22
|
+
readonly failureThreshold: number;
|
|
23
|
+
/** Time in ms before OPEN → HALF_OPEN (default: 30_000) */
|
|
24
|
+
readonly resetTimeoutMs: number;
|
|
25
|
+
/** Number of successful probes in HALF_OPEN before closing (default: 2) */
|
|
26
|
+
readonly halfOpenSuccessThreshold: number;
|
|
27
|
+
/** Called on state change */
|
|
28
|
+
readonly onStateChange?: (name: string, from: CircuitState, to: CircuitState) => void;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
export interface CircuitBreaker {
|
|
32
|
+
/** Execute a function through the circuit breaker */
|
|
33
|
+
execute<T>(fn: () => Promise<T>): Promise<T>;
|
|
34
|
+
/** Get current state */
|
|
35
|
+
readonly state: CircuitState;
|
|
36
|
+
/** Get name */
|
|
37
|
+
readonly name: string;
|
|
38
|
+
/** Get failure count in current window */
|
|
39
|
+
readonly failureCount: number;
|
|
40
|
+
/** Reset to CLOSED state */
|
|
41
|
+
reset(): void;
|
|
42
|
+
}
|
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Domain event system — typed events emitted by application services
|
|
3
|
+
* and consumed by subscribers (webhooks, SSE, WebSocket, audit log, etc.).
|
|
4
|
+
*
|
|
5
|
+
* Zero-dependency, strongly typed, Result-based.
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
import type { UserId } from "../types/brand.js";
|
|
9
|
+
|
|
10
|
+
// ─── Domain Event Types ───
|
|
11
|
+
|
|
12
|
+
export const DomainEventType = {
|
|
13
|
+
USER_REGISTERED: "user.registered",
|
|
14
|
+
USER_DELETED: "user.deleted",
|
|
15
|
+
USER_UPDATED: "user.updated",
|
|
16
|
+
LOGIN_SUCCESS: "login.success",
|
|
17
|
+
LOGIN_FAILED: "login.failed",
|
|
18
|
+
LOGOUT: "logout",
|
|
19
|
+
PASSWORD_CHANGED: "password.changed",
|
|
20
|
+
PASSWORD_RESET: "password.reset",
|
|
21
|
+
EMAIL_VERIFIED: "email.verified",
|
|
22
|
+
MFA_ENABLED: "mfa.enabled",
|
|
23
|
+
MFA_DISABLED: "mfa.disabled",
|
|
24
|
+
API_KEY_CREATED: "api_key.created",
|
|
25
|
+
API_KEY_REVOKED: "api_key.revoked",
|
|
26
|
+
ACCOUNT_LOCKED: "account.locked",
|
|
27
|
+
ACCOUNT_UNLOCKED: "account.unlocked",
|
|
28
|
+
} as const;
|
|
29
|
+
|
|
30
|
+
export type DomainEventType = (typeof DomainEventType)[keyof typeof DomainEventType];
|
|
31
|
+
|
|
32
|
+
// ─── Domain Event ───
|
|
33
|
+
|
|
34
|
+
export interface DomainEvent<T extends DomainEventType = DomainEventType> {
|
|
35
|
+
/** Unique event ID */
|
|
36
|
+
readonly id: string;
|
|
37
|
+
/** Event type discriminator */
|
|
38
|
+
readonly type: T;
|
|
39
|
+
/** ISO 8601 timestamp */
|
|
40
|
+
readonly timestamp: string;
|
|
41
|
+
/** User who triggered the event (if applicable) */
|
|
42
|
+
readonly userId?: UserId | undefined;
|
|
43
|
+
/** Event-specific payload */
|
|
44
|
+
readonly payload: Readonly<Record<string, unknown>>;
|
|
45
|
+
/** Source IP address */
|
|
46
|
+
readonly ip?: string | undefined;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
// ─── Event Handler ───
|
|
50
|
+
|
|
51
|
+
export type EventHandler = (event: DomainEvent) => void | Promise<void>;
|
|
52
|
+
|
|
53
|
+
// ─── Event Bus Port ───
|
|
54
|
+
|
|
55
|
+
export interface EventBus {
|
|
56
|
+
/**
|
|
57
|
+
* Publish a domain event to all subscribers.
|
|
58
|
+
* Fire-and-forget semantics — errors in handlers are logged, not propagated.
|
|
59
|
+
*/
|
|
60
|
+
publish(event: DomainEvent): void;
|
|
61
|
+
|
|
62
|
+
/**
|
|
63
|
+
* Subscribe to a specific event type.
|
|
64
|
+
* Returns an unsubscribe function.
|
|
65
|
+
*/
|
|
66
|
+
subscribe(type: DomainEventType, handler: EventHandler): () => void;
|
|
67
|
+
|
|
68
|
+
/**
|
|
69
|
+
* Subscribe to ALL event types (wildcard).
|
|
70
|
+
* Returns an unsubscribe function.
|
|
71
|
+
*/
|
|
72
|
+
subscribeAll(handler: EventHandler): () => void;
|
|
73
|
+
|
|
74
|
+
/**
|
|
75
|
+
* Number of registered handlers (for diagnostics).
|
|
76
|
+
*/
|
|
77
|
+
readonly handlerCount: number;
|
|
78
|
+
}
|