@enderworld/onlyapi 1.5.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (160) hide show
  1. package/CHANGELOG.md +201 -0
  2. package/LICENSE +21 -0
  3. package/README.md +338 -0
  4. package/dist/cli.js +14 -0
  5. package/package.json +69 -0
  6. package/src/application/dtos/admin.dto.ts +25 -0
  7. package/src/application/dtos/auth.dto.ts +97 -0
  8. package/src/application/dtos/index.ts +40 -0
  9. package/src/application/index.ts +2 -0
  10. package/src/application/services/admin.service.ts +150 -0
  11. package/src/application/services/api-key.service.ts +65 -0
  12. package/src/application/services/auth.service.ts +606 -0
  13. package/src/application/services/health.service.ts +97 -0
  14. package/src/application/services/index.ts +10 -0
  15. package/src/application/services/user.service.ts +95 -0
  16. package/src/cli/commands/help.ts +86 -0
  17. package/src/cli/commands/init.ts +301 -0
  18. package/src/cli/commands/upgrade.ts +471 -0
  19. package/src/cli/index.ts +76 -0
  20. package/src/cli/ui.ts +189 -0
  21. package/src/cluster.ts +62 -0
  22. package/src/core/entities/index.ts +1 -0
  23. package/src/core/entities/user.entity.ts +24 -0
  24. package/src/core/errors/app-error.ts +81 -0
  25. package/src/core/errors/index.ts +15 -0
  26. package/src/core/index.ts +7 -0
  27. package/src/core/ports/account-lockout.ts +15 -0
  28. package/src/core/ports/alert-sink.ts +27 -0
  29. package/src/core/ports/api-key.ts +37 -0
  30. package/src/core/ports/audit-log.ts +46 -0
  31. package/src/core/ports/cache.ts +24 -0
  32. package/src/core/ports/circuit-breaker.ts +42 -0
  33. package/src/core/ports/event-bus.ts +78 -0
  34. package/src/core/ports/index.ts +62 -0
  35. package/src/core/ports/job-queue.ts +73 -0
  36. package/src/core/ports/logger.ts +21 -0
  37. package/src/core/ports/metrics.ts +49 -0
  38. package/src/core/ports/oauth.ts +55 -0
  39. package/src/core/ports/password-hasher.ts +10 -0
  40. package/src/core/ports/password-history.ts +23 -0
  41. package/src/core/ports/password-policy.ts +43 -0
  42. package/src/core/ports/refresh-token-store.ts +37 -0
  43. package/src/core/ports/retry.ts +23 -0
  44. package/src/core/ports/token-blacklist.ts +16 -0
  45. package/src/core/ports/token-service.ts +23 -0
  46. package/src/core/ports/totp-service.ts +16 -0
  47. package/src/core/ports/user.repository.ts +40 -0
  48. package/src/core/ports/verification-token.ts +41 -0
  49. package/src/core/ports/webhook.ts +58 -0
  50. package/src/core/types/brand.ts +19 -0
  51. package/src/core/types/index.ts +19 -0
  52. package/src/core/types/pagination.ts +28 -0
  53. package/src/core/types/result.ts +52 -0
  54. package/src/infrastructure/alerting/index.ts +1 -0
  55. package/src/infrastructure/alerting/webhook.ts +100 -0
  56. package/src/infrastructure/cache/in-memory-cache.ts +111 -0
  57. package/src/infrastructure/cache/index.ts +6 -0
  58. package/src/infrastructure/cache/redis-cache.ts +204 -0
  59. package/src/infrastructure/config/config.ts +185 -0
  60. package/src/infrastructure/config/index.ts +1 -0
  61. package/src/infrastructure/database/in-memory-user.repository.ts +134 -0
  62. package/src/infrastructure/database/index.ts +37 -0
  63. package/src/infrastructure/database/migrations/001_create_users.ts +26 -0
  64. package/src/infrastructure/database/migrations/002_create_token_blacklist.ts +21 -0
  65. package/src/infrastructure/database/migrations/003_create_audit_log.ts +31 -0
  66. package/src/infrastructure/database/migrations/004_auth_platform.ts +112 -0
  67. package/src/infrastructure/database/migrations/runner.ts +120 -0
  68. package/src/infrastructure/database/mssql/index.ts +14 -0
  69. package/src/infrastructure/database/mssql/migrations.ts +299 -0
  70. package/src/infrastructure/database/mssql/mssql-account-lockout.ts +95 -0
  71. package/src/infrastructure/database/mssql/mssql-api-keys.ts +146 -0
  72. package/src/infrastructure/database/mssql/mssql-audit-log.ts +86 -0
  73. package/src/infrastructure/database/mssql/mssql-oauth-accounts.ts +118 -0
  74. package/src/infrastructure/database/mssql/mssql-password-history.ts +71 -0
  75. package/src/infrastructure/database/mssql/mssql-refresh-token-store.ts +144 -0
  76. package/src/infrastructure/database/mssql/mssql-token-blacklist.ts +54 -0
  77. package/src/infrastructure/database/mssql/mssql-user.repository.ts +263 -0
  78. package/src/infrastructure/database/mssql/mssql-verification-tokens.ts +120 -0
  79. package/src/infrastructure/database/postgres/index.ts +14 -0
  80. package/src/infrastructure/database/postgres/migrations.ts +235 -0
  81. package/src/infrastructure/database/postgres/pg-account-lockout.ts +75 -0
  82. package/src/infrastructure/database/postgres/pg-api-keys.ts +126 -0
  83. package/src/infrastructure/database/postgres/pg-audit-log.ts +74 -0
  84. package/src/infrastructure/database/postgres/pg-oauth-accounts.ts +101 -0
  85. package/src/infrastructure/database/postgres/pg-password-history.ts +61 -0
  86. package/src/infrastructure/database/postgres/pg-refresh-token-store.ts +117 -0
  87. package/src/infrastructure/database/postgres/pg-token-blacklist.ts +48 -0
  88. package/src/infrastructure/database/postgres/pg-user.repository.ts +237 -0
  89. package/src/infrastructure/database/postgres/pg-verification-tokens.ts +97 -0
  90. package/src/infrastructure/database/sqlite-account-lockout.ts +97 -0
  91. package/src/infrastructure/database/sqlite-api-keys.ts +155 -0
  92. package/src/infrastructure/database/sqlite-audit-log.ts +90 -0
  93. package/src/infrastructure/database/sqlite-oauth-accounts.ts +105 -0
  94. package/src/infrastructure/database/sqlite-password-history.ts +54 -0
  95. package/src/infrastructure/database/sqlite-refresh-token-store.ts +122 -0
  96. package/src/infrastructure/database/sqlite-token-blacklist.ts +47 -0
  97. package/src/infrastructure/database/sqlite-user.repository.ts +260 -0
  98. package/src/infrastructure/database/sqlite-verification-tokens.ts +112 -0
  99. package/src/infrastructure/events/event-bus.ts +105 -0
  100. package/src/infrastructure/events/event-factory.ts +31 -0
  101. package/src/infrastructure/events/in-memory-webhook-registry.ts +67 -0
  102. package/src/infrastructure/events/index.ts +4 -0
  103. package/src/infrastructure/events/webhook-dispatcher.ts +114 -0
  104. package/src/infrastructure/index.ts +58 -0
  105. package/src/infrastructure/jobs/index.ts +1 -0
  106. package/src/infrastructure/jobs/job-queue.ts +185 -0
  107. package/src/infrastructure/logging/index.ts +1 -0
  108. package/src/infrastructure/logging/logger.ts +63 -0
  109. package/src/infrastructure/metrics/index.ts +1 -0
  110. package/src/infrastructure/metrics/prometheus.ts +231 -0
  111. package/src/infrastructure/oauth/github.ts +116 -0
  112. package/src/infrastructure/oauth/google.ts +83 -0
  113. package/src/infrastructure/oauth/index.ts +2 -0
  114. package/src/infrastructure/resilience/circuit-breaker.ts +133 -0
  115. package/src/infrastructure/resilience/index.ts +2 -0
  116. package/src/infrastructure/resilience/retry.ts +50 -0
  117. package/src/infrastructure/security/account-lockout.ts +73 -0
  118. package/src/infrastructure/security/index.ts +6 -0
  119. package/src/infrastructure/security/password-hasher.ts +31 -0
  120. package/src/infrastructure/security/password-policy.ts +77 -0
  121. package/src/infrastructure/security/token-blacklist.ts +45 -0
  122. package/src/infrastructure/security/token-service.ts +144 -0
  123. package/src/infrastructure/security/totp-service.ts +142 -0
  124. package/src/infrastructure/tracing/index.ts +7 -0
  125. package/src/infrastructure/tracing/trace-context.ts +93 -0
  126. package/src/main.ts +479 -0
  127. package/src/presentation/context.ts +26 -0
  128. package/src/presentation/handlers/admin.handler.ts +114 -0
  129. package/src/presentation/handlers/api-key.handler.ts +68 -0
  130. package/src/presentation/handlers/auth.handler.ts +218 -0
  131. package/src/presentation/handlers/health.handler.ts +27 -0
  132. package/src/presentation/handlers/index.ts +15 -0
  133. package/src/presentation/handlers/metrics.handler.ts +21 -0
  134. package/src/presentation/handlers/oauth.handler.ts +61 -0
  135. package/src/presentation/handlers/openapi.handler.ts +543 -0
  136. package/src/presentation/handlers/response.ts +29 -0
  137. package/src/presentation/handlers/sse.handler.ts +165 -0
  138. package/src/presentation/handlers/user.handler.ts +81 -0
  139. package/src/presentation/handlers/webhook.handler.ts +92 -0
  140. package/src/presentation/handlers/websocket.handler.ts +226 -0
  141. package/src/presentation/i18n/index.ts +254 -0
  142. package/src/presentation/index.ts +5 -0
  143. package/src/presentation/middleware/api-key.ts +18 -0
  144. package/src/presentation/middleware/auth.ts +39 -0
  145. package/src/presentation/middleware/cors.ts +41 -0
  146. package/src/presentation/middleware/index.ts +12 -0
  147. package/src/presentation/middleware/rate-limit.ts +65 -0
  148. package/src/presentation/middleware/security-headers.ts +18 -0
  149. package/src/presentation/middleware/validate.ts +16 -0
  150. package/src/presentation/middleware/versioning.ts +69 -0
  151. package/src/presentation/routes/index.ts +1 -0
  152. package/src/presentation/routes/router.ts +272 -0
  153. package/src/presentation/server.ts +381 -0
  154. package/src/shared/cli.ts +294 -0
  155. package/src/shared/container.ts +65 -0
  156. package/src/shared/index.ts +2 -0
  157. package/src/shared/log-format.ts +148 -0
  158. package/src/shared/utils/id.ts +5 -0
  159. package/src/shared/utils/index.ts +2 -0
  160. package/src/shared/utils/timing-safe.ts +20 -0
@@ -0,0 +1,52 @@
1
+ /**
2
+ * Result monad — eliminates throw-based control flow.
3
+ * Every fallible operation returns Result<T, E> instead of throwing.
4
+ */
5
+
6
+ export type Result<T, E = AppError> = Ok<T> | Err<E>;
7
+
8
+ export interface Ok<T> {
9
+ readonly ok: true;
10
+ readonly value: T;
11
+ }
12
+
13
+ export interface Err<E> {
14
+ readonly ok: false;
15
+ readonly error: E;
16
+ }
17
+
18
+ export const ok = <T>(value: T): Ok<T> => ({ ok: true, value });
19
+ export const err = <E>(error: E): Err<E> => ({ ok: false, error });
20
+
21
+ /** Map over the success value */
22
+ export const map = <T, U, E>(result: Result<T, E>, fn: (v: T) => U): Result<U, E> =>
23
+ result.ok ? ok(fn(result.value)) : result;
24
+
25
+ /** FlatMap / chain */
26
+ export const flatMap = <T, U, E>(result: Result<T, E>, fn: (v: T) => Result<U, E>): Result<U, E> =>
27
+ result.ok ? fn(result.value) : result;
28
+
29
+ /** Unwrap with a default */
30
+ export const unwrapOr = <T, E>(result: Result<T, E>, fallback: T): T =>
31
+ result.ok ? result.value : fallback;
32
+
33
+ /** Wrap a throwing function into a Result */
34
+ export const tryCatch = <T>(fn: () => T): Result<T, unknown> => {
35
+ try {
36
+ return ok(fn());
37
+ } catch (e: unknown) {
38
+ return err(e);
39
+ }
40
+ };
41
+
42
+ /** Wrap an async throwing function into a Result */
43
+ export const tryCatchAsync = async <T>(fn: () => Promise<T>): Promise<Result<T, unknown>> => {
44
+ try {
45
+ return ok(await fn());
46
+ } catch (e: unknown) {
47
+ return err(e);
48
+ }
49
+ };
50
+
51
+ // Re-export the error type referenced above
52
+ import type { AppError } from "../errors/app-error.js";
@@ -0,0 +1 @@
1
+ export { createWebhookAlertSink, createNoopAlertSink } from "./webhook.js";
@@ -0,0 +1,100 @@
1
+ /**
2
+ * Webhook alert sink — sends alert notifications to configured webhook URLs.
3
+ *
4
+ * Features:
5
+ * - HTTP POST with JSON payload
6
+ * - Retry with backoff on transient failures
7
+ * - Configurable via ALERT_WEBHOOK_URL env var
8
+ * - Non-blocking — fire-and-forget with error logging
9
+ */
10
+
11
+ import type { AlertPayload, AlertSink } from "../../core/ports/alert-sink.js";
12
+ import type { Logger } from "../../core/ports/logger.js";
13
+
14
+ interface WebhookAlertSinkOptions {
15
+ /** Webhook URL to POST alerts to */
16
+ readonly url: string;
17
+ /** Request timeout in ms (default: 5000) */
18
+ readonly timeoutMs?: number;
19
+ /** Max retry attempts (default: 2) */
20
+ readonly maxRetries?: number;
21
+ /** Logger for error reporting */
22
+ readonly logger: Logger;
23
+ }
24
+
25
+ export const createWebhookAlertSink = (options: WebhookAlertSinkOptions): AlertSink => {
26
+ const { url, logger } = options;
27
+ const timeoutMs = options.timeoutMs ?? 5_000;
28
+ const maxRetries = options.maxRetries ?? 2;
29
+
30
+ return {
31
+ get enabled(): boolean {
32
+ return url.length > 0;
33
+ },
34
+
35
+ async send(payload: AlertPayload): Promise<void> {
36
+ const body = JSON.stringify(payload);
37
+ let lastError: unknown;
38
+
39
+ for (let attempt = 0; attempt <= maxRetries; attempt++) {
40
+ try {
41
+ const controller = new AbortController();
42
+ const timer = setTimeout(() => controller.abort(), timeoutMs);
43
+
44
+ const response = await fetch(url, {
45
+ method: "POST",
46
+ headers: {
47
+ "Content-Type": "application/json",
48
+ "User-Agent": "onlyApi/1.3.0",
49
+ },
50
+ body,
51
+ signal: controller.signal,
52
+ });
53
+
54
+ clearTimeout(timer);
55
+
56
+ if (response.ok) {
57
+ logger.debug("Alert sent successfully", {
58
+ level: payload.level,
59
+ title: payload.title,
60
+ attempt: attempt + 1,
61
+ });
62
+ return;
63
+ }
64
+
65
+ lastError = new Error(`Webhook returned ${response.status}`);
66
+ logger.warn("Alert webhook returned non-OK status", {
67
+ status: response.status,
68
+ attempt: attempt + 1,
69
+ });
70
+ } catch (error: unknown) {
71
+ lastError = error;
72
+ if (attempt < maxRetries) {
73
+ // Exponential backoff: 500ms, 1000ms
74
+ const delay = 500 * 2 ** attempt;
75
+ await new Promise((r) => setTimeout(r, delay));
76
+ }
77
+ }
78
+ }
79
+
80
+ logger.error("Failed to send alert after retries", {
81
+ level: payload.level,
82
+ title: payload.title,
83
+ error: lastError instanceof Error ? lastError.message : String(lastError),
84
+ maxRetries,
85
+ });
86
+ },
87
+ };
88
+ };
89
+
90
+ /**
91
+ * No-op alert sink for when no webhook URL is configured.
92
+ */
93
+ export const createNoopAlertSink = (): AlertSink => ({
94
+ get enabled(): boolean {
95
+ return false;
96
+ },
97
+ async send(_payload: AlertPayload): Promise<void> {
98
+ // intentionally empty — no webhook configured
99
+ },
100
+ });
@@ -0,0 +1,111 @@
1
+ /**
2
+ * In-memory cache — zero-dep implementation for development / single-process.
3
+ * Swap with Redis adapter for production multi-process deployments.
4
+ */
5
+
6
+ import { internal } from "../../core/errors/app-error.js";
7
+ import type { AppError } from "../../core/errors/app-error.js";
8
+ import type { Cache } from "../../core/ports/cache.js";
9
+ import { type Result, err, ok } from "../../core/types/result.js";
10
+
11
+ interface CacheEntry<T = unknown> {
12
+ value: T;
13
+ expiresAt: number | null; // null = no expiry
14
+ }
15
+
16
+ export const createInMemoryCache = (): Cache => {
17
+ const store = new Map<string, CacheEntry>();
18
+
19
+ /** Remove expired entries lazily */
20
+ const isExpired = (entry: CacheEntry): boolean =>
21
+ entry.expiresAt !== null && entry.expiresAt <= Date.now();
22
+
23
+ return {
24
+ async get<T = unknown>(key: string): Promise<Result<T | null, AppError>> {
25
+ try {
26
+ const entry = store.get(key);
27
+ if (!entry || isExpired(entry)) {
28
+ if (entry) store.delete(key);
29
+ return ok(null);
30
+ }
31
+ return ok(entry.value as T);
32
+ } catch (e: unknown) {
33
+ return err(internal("Cache error", e));
34
+ }
35
+ },
36
+
37
+ async set<T = unknown>(key: string, value: T, ttlMs?: number): Promise<Result<void, AppError>> {
38
+ try {
39
+ store.set(key, {
40
+ value,
41
+ expiresAt: ttlMs !== undefined ? Date.now() + ttlMs : null,
42
+ });
43
+ return ok(undefined);
44
+ } catch (e: unknown) {
45
+ return err(internal("Cache error", e));
46
+ }
47
+ },
48
+
49
+ async del(key: string): Promise<Result<boolean, AppError>> {
50
+ try {
51
+ return ok(store.delete(key));
52
+ } catch (e: unknown) {
53
+ return err(internal("Cache error", e));
54
+ }
55
+ },
56
+
57
+ async has(key: string): Promise<Result<boolean, AppError>> {
58
+ try {
59
+ const entry = store.get(key);
60
+ if (!entry || isExpired(entry)) {
61
+ if (entry) store.delete(key);
62
+ return ok(false);
63
+ }
64
+ return ok(true);
65
+ } catch (e: unknown) {
66
+ return err(internal("Cache error", e));
67
+ }
68
+ },
69
+
70
+ async incr(key: string, delta = 1): Promise<Result<number, AppError>> {
71
+ try {
72
+ const entry = store.get(key);
73
+ if (!entry || isExpired(entry)) {
74
+ store.set(key, { value: delta, expiresAt: entry?.expiresAt ?? null });
75
+ return ok(delta);
76
+ }
77
+ const newVal = (entry.value as number) + delta;
78
+ entry.value = newVal;
79
+ return ok(newVal);
80
+ } catch (e: unknown) {
81
+ return err(internal("Cache error", e));
82
+ }
83
+ },
84
+
85
+ async delPattern(pattern: string): Promise<Result<number, AppError>> {
86
+ try {
87
+ // Convert glob pattern to regex
88
+ const regexStr = pattern
89
+ .replace(/[.+^${}()|[\]\\]/g, "\\$&")
90
+ .replace(/\*/g, ".*")
91
+ .replace(/\?/g, ".");
92
+ const regex = new RegExp(`^${regexStr}$`);
93
+
94
+ let count = 0;
95
+ for (const key of store.keys()) {
96
+ if (regex.test(key)) {
97
+ store.delete(key);
98
+ count++;
99
+ }
100
+ }
101
+ return ok(count);
102
+ } catch (e: unknown) {
103
+ return err(internal("Cache error", e));
104
+ }
105
+ },
106
+
107
+ async close(): Promise<void> {
108
+ store.clear();
109
+ },
110
+ };
111
+ };
@@ -0,0 +1,6 @@
1
+ /**
2
+ * Cache adapters — barrel export.
3
+ */
4
+
5
+ export { createInMemoryCache } from "./in-memory-cache.js";
6
+ export { createRedisCache } from "./redis-cache.js";
@@ -0,0 +1,204 @@
1
+ /**
2
+ * Redis cache adapter — using raw TCP via Bun.connect().
3
+ *
4
+ * Zero external dependencies. Implements the RESP (Redis Serialization Protocol)
5
+ * directly over a TCP socket. Supports: GET, SET, DEL, EXISTS, INCRBY, KEYS, QUIT.
6
+ *
7
+ * For production, considers connection pooling. This single-connection adapter
8
+ * is suitable for moderate load. Swap to ioredis or similar for advanced features.
9
+ */
10
+
11
+ import { internal } from "../../core/errors/app-error.js";
12
+ import type { AppError } from "../../core/errors/app-error.js";
13
+ import type { Cache } from "../../core/ports/cache.js";
14
+ import type { Logger } from "../../core/ports/logger.js";
15
+ import { type Result, err, ok } from "../../core/types/result.js";
16
+
17
+ interface RedisConfig {
18
+ readonly host: string;
19
+ readonly port: number;
20
+ readonly password?: string | undefined;
21
+ readonly db?: number | undefined;
22
+ }
23
+
24
+ interface RedisCacheDeps {
25
+ readonly config: RedisConfig;
26
+ readonly logger: Logger;
27
+ }
28
+
29
+ /**
30
+ * Encode a RESP command: *<n>\r\n$<len>\r\n<arg>\r\n...
31
+ */
32
+ const encodeCommand = (args: readonly string[]): string => {
33
+ let cmd = `*${args.length}\r\n`;
34
+ for (const arg of args) {
35
+ cmd += `$${Buffer.byteLength(arg)}\r\n${arg}\r\n`;
36
+ }
37
+ return cmd;
38
+ };
39
+
40
+ export const createRedisCache = async (deps: RedisCacheDeps): Promise<Cache> => {
41
+ const { config, logger } = deps;
42
+
43
+ let responseBuffer = "";
44
+ let resolveResponse: ((value: string) => void) | null = null;
45
+
46
+ const socket = await Bun.connect({
47
+ hostname: config.host,
48
+ port: config.port,
49
+ socket: {
50
+ data(_socket, data) {
51
+ responseBuffer += data.toString();
52
+ if (resolveResponse && responseBuffer.includes("\r\n")) {
53
+ const resolve = resolveResponse;
54
+ resolveResponse = null;
55
+ resolve(responseBuffer);
56
+ responseBuffer = "";
57
+ }
58
+ },
59
+ open() {
60
+ logger.debug("Redis connection established", {
61
+ host: config.host,
62
+ port: config.port,
63
+ });
64
+ },
65
+ close() {
66
+ logger.debug("Redis connection closed");
67
+ },
68
+ error(_socket, error) {
69
+ logger.error("Redis connection error", { error: error.message });
70
+ },
71
+ },
72
+ });
73
+
74
+ const sendCommand = async (args: readonly string[]): Promise<string> => {
75
+ const cmd = encodeCommand(args);
76
+ return new Promise<string>((resolve) => {
77
+ resolveResponse = resolve;
78
+ socket.write(cmd);
79
+ });
80
+ };
81
+
82
+ /**
83
+ * Parse a simple RESP response.
84
+ * +OK, -ERR, :42, $-1 (nil), $5\r\nhello
85
+ */
86
+ const parseResponse = (raw: string): string | null => {
87
+ if (raw.startsWith("+")) return raw.substring(1, raw.indexOf("\r\n"));
88
+ if (raw.startsWith("-")) throw new Error(raw.substring(1, raw.indexOf("\r\n")));
89
+ if (raw.startsWith(":")) return raw.substring(1, raw.indexOf("\r\n"));
90
+ if (raw.startsWith("$-1")) return null;
91
+ if (raw.startsWith("$")) {
92
+ const firstNewline = raw.indexOf("\r\n");
93
+ const dataStart = firstNewline + 2;
94
+ const len = Number.parseInt(raw.substring(1, firstNewline), 10);
95
+ return raw.substring(dataStart, dataStart + len);
96
+ }
97
+ return raw;
98
+ };
99
+
100
+ // Authenticate if password provided
101
+ if (config.password) {
102
+ await sendCommand(["AUTH", config.password]);
103
+ }
104
+ // Select database if specified
105
+ if (config.db !== undefined) {
106
+ await sendCommand(["SELECT", String(config.db)]);
107
+ }
108
+
109
+ return {
110
+ async get<T = unknown>(key: string): Promise<Result<T | null, AppError>> {
111
+ try {
112
+ const raw = await sendCommand(["GET", key]);
113
+ const value = parseResponse(raw);
114
+ if (value === null) return ok(null);
115
+ try {
116
+ return ok(JSON.parse(value) as T);
117
+ } catch {
118
+ return ok(value as T);
119
+ }
120
+ } catch (e: unknown) {
121
+ return err(internal("Redis error", e));
122
+ }
123
+ },
124
+
125
+ async set<T = unknown>(key: string, value: T, ttlMs?: number): Promise<Result<void, AppError>> {
126
+ try {
127
+ const serialized = typeof value === "string" ? value : JSON.stringify(value);
128
+ if (ttlMs !== undefined) {
129
+ await sendCommand(["SET", key, serialized, "PX", String(ttlMs)]);
130
+ } else {
131
+ await sendCommand(["SET", key, serialized]);
132
+ }
133
+ return ok(undefined);
134
+ } catch (e: unknown) {
135
+ return err(internal("Redis error", e));
136
+ }
137
+ },
138
+
139
+ async del(key: string): Promise<Result<boolean, AppError>> {
140
+ try {
141
+ const raw = await sendCommand(["DEL", key]);
142
+ const value = parseResponse(raw);
143
+ return ok(value === "1");
144
+ } catch (e: unknown) {
145
+ return err(internal("Redis error", e));
146
+ }
147
+ },
148
+
149
+ async has(key: string): Promise<Result<boolean, AppError>> {
150
+ try {
151
+ const raw = await sendCommand(["EXISTS", key]);
152
+ const value = parseResponse(raw);
153
+ return ok(value === "1");
154
+ } catch (e: unknown) {
155
+ return err(internal("Redis error", e));
156
+ }
157
+ },
158
+
159
+ async incr(key: string, delta = 1): Promise<Result<number, AppError>> {
160
+ try {
161
+ const raw = await sendCommand(["INCRBY", key, String(delta)]);
162
+ const value = parseResponse(raw);
163
+ return ok(Number(value));
164
+ } catch (e: unknown) {
165
+ return err(internal("Redis error", e));
166
+ }
167
+ },
168
+
169
+ async delPattern(pattern: string): Promise<Result<number, AppError>> {
170
+ try {
171
+ // KEYS is O(n) — acceptable for admin/maintenance operations
172
+ const raw = await sendCommand(["KEYS", pattern]);
173
+ // Parse array response
174
+ if (raw.startsWith("*0")) return ok(0);
175
+
176
+ const lines = raw.split("\r\n").filter(Boolean);
177
+ const keys: string[] = [];
178
+ for (let i = 1; i < lines.length; i++) {
179
+ const line = lines[i];
180
+ if (line && !line.startsWith("$")) {
181
+ keys.push(line);
182
+ }
183
+ }
184
+
185
+ if (keys.length === 0) return ok(0);
186
+
187
+ const delRaw = await sendCommand(["DEL", ...keys]);
188
+ const delVal = parseResponse(delRaw);
189
+ return ok(Number(delVal));
190
+ } catch (e: unknown) {
191
+ return err(internal("Redis error", e));
192
+ }
193
+ },
194
+
195
+ async close(): Promise<void> {
196
+ try {
197
+ await sendCommand(["QUIT"]);
198
+ } catch {
199
+ // Ignore errors during close
200
+ }
201
+ socket.end();
202
+ },
203
+ };
204
+ };
@@ -0,0 +1,185 @@
1
+ import { z } from "zod";
2
+ import { printConfigError } from "../../shared/cli.js";
3
+
4
+ /**
5
+ * Application config — validated at boot via Zod.
6
+ * Fails fast with clear messages if env vars are missing.
7
+ */
8
+ const configSchema = z.object({
9
+ env: z.enum(["development", "production", "test"]).default("development"),
10
+ port: z.coerce.number().int().min(1).max(65535).default(3000),
11
+ host: z.string().min(1).default("0.0.0.0"),
12
+
13
+ jwt: z.object({
14
+ secret: z.string().min(32),
15
+ expiresIn: z.string().default("15m"),
16
+ refreshExpiresIn: z.string().default("7d"),
17
+ }),
18
+
19
+ cors: z.object({
20
+ origins: z
21
+ .string()
22
+ .transform((s) => s.split(",").map((o) => o.trim()))
23
+ .default("*"),
24
+ }),
25
+
26
+ rateLimit: z.object({
27
+ windowMs: z.coerce.number().int().positive().default(60_000),
28
+ maxRequests: z.coerce.number().int().positive().default(100),
29
+ }),
30
+
31
+ log: z.object({
32
+ level: z.enum(["debug", "info", "warn", "error", "fatal"]).default("info"),
33
+ format: z.enum(["pretty", "json"]).default("pretty"),
34
+ }),
35
+
36
+ database: z.object({
37
+ driver: z.enum(["sqlite", "postgres", "mssql"]).default("sqlite"),
38
+ url: z.string().optional(),
39
+ path: z.string().default("data/onlyapi.sqlite"),
40
+ }),
41
+
42
+ redis: z.object({
43
+ enabled: z
44
+ .enum(["true", "false"])
45
+ .transform((v) => v === "true")
46
+ .default("false"),
47
+ host: z.string().default("127.0.0.1"),
48
+ port: z.coerce.number().int().min(1).max(65535).default(6379),
49
+ password: z.string().optional(),
50
+ db: z.coerce.number().int().min(0).max(15).default(0),
51
+ }),
52
+
53
+ i18n: z.object({
54
+ defaultLocale: z.string().default("en"),
55
+ supportedLocales: z
56
+ .string()
57
+ .transform((s) => s.split(",").map((l) => l.trim()))
58
+ .default("en"),
59
+ }),
60
+
61
+ lockout: z.object({
62
+ maxAttempts: z.coerce.number().int().positive().default(5),
63
+ durationMs: z.coerce.number().int().positive().default(900_000), // 15 minutes
64
+ }),
65
+
66
+ alerting: z.object({
67
+ webhookUrl: z.string().url().optional(),
68
+ timeoutMs: z.coerce.number().int().positive().default(5_000),
69
+ }),
70
+
71
+ circuitBreaker: z.object({
72
+ failureThreshold: z.coerce.number().int().positive().default(5),
73
+ resetTimeoutMs: z.coerce.number().int().positive().default(30_000),
74
+ halfOpenSuccessThreshold: z.coerce.number().int().positive().default(2),
75
+ }),
76
+
77
+ passwordPolicy: z.object({
78
+ minLength: z.coerce.number().int().positive().default(8),
79
+ requireUppercase: z
80
+ .enum(["true", "false"])
81
+ .transform((v) => v === "true")
82
+ .default("true"),
83
+ requireLowercase: z
84
+ .enum(["true", "false"])
85
+ .transform((v) => v === "true")
86
+ .default("true"),
87
+ requireDigit: z
88
+ .enum(["true", "false"])
89
+ .transform((v) => v === "true")
90
+ .default("true"),
91
+ requireSpecial: z
92
+ .enum(["true", "false"])
93
+ .transform((v) => v === "true")
94
+ .default("false"),
95
+ historyCount: z.coerce.number().int().min(0).default(5),
96
+ maxAgeDays: z.coerce.number().int().min(0).default(0),
97
+ }),
98
+
99
+ oauth: z.object({
100
+ googleClientId: z.string().optional(),
101
+ googleClientSecret: z.string().optional(),
102
+ githubClientId: z.string().optional(),
103
+ githubClientSecret: z.string().optional(),
104
+ }),
105
+ });
106
+
107
+ export type AppConfig = z.infer<typeof configSchema>;
108
+
109
+ export const loadConfig = (): AppConfig => {
110
+ const result = configSchema.safeParse({
111
+ env: Bun.env["NODE_ENV"],
112
+ port: Bun.env["PORT"],
113
+ host: Bun.env["HOST"],
114
+ jwt: Bun.env["JWT_SECRET"]
115
+ ? {
116
+ secret: Bun.env["JWT_SECRET"],
117
+ expiresIn: Bun.env["JWT_EXPIRES_IN"],
118
+ refreshExpiresIn: Bun.env["JWT_REFRESH_EXPIRES_IN"],
119
+ }
120
+ : undefined,
121
+ cors: {
122
+ origins: Bun.env["CORS_ORIGINS"],
123
+ },
124
+ rateLimit: {
125
+ windowMs: Bun.env["RATE_LIMIT_WINDOW_MS"],
126
+ maxRequests: Bun.env["RATE_LIMIT_MAX_REQUESTS"],
127
+ },
128
+ log: {
129
+ level: Bun.env["LOG_LEVEL"],
130
+ format: Bun.env["LOG_FORMAT"],
131
+ },
132
+ database: {
133
+ driver: Bun.env["DATABASE_DRIVER"],
134
+ url: Bun.env["DATABASE_URL"],
135
+ path: Bun.env["DATABASE_PATH"],
136
+ },
137
+ redis: {
138
+ enabled: Bun.env["REDIS_ENABLED"],
139
+ host: Bun.env["REDIS_HOST"],
140
+ port: Bun.env["REDIS_PORT"],
141
+ password: Bun.env["REDIS_PASSWORD"],
142
+ db: Bun.env["REDIS_DB"],
143
+ },
144
+ i18n: {
145
+ defaultLocale: Bun.env["I18N_DEFAULT_LOCALE"],
146
+ supportedLocales: Bun.env["I18N_SUPPORTED_LOCALES"],
147
+ },
148
+ lockout: {
149
+ maxAttempts: Bun.env["LOCKOUT_MAX_ATTEMPTS"],
150
+ durationMs: Bun.env["LOCKOUT_DURATION_MS"],
151
+ },
152
+ alerting: {
153
+ webhookUrl: Bun.env["ALERT_WEBHOOK_URL"],
154
+ timeoutMs: Bun.env["ALERT_TIMEOUT_MS"],
155
+ },
156
+ circuitBreaker: {
157
+ failureThreshold: Bun.env["CB_FAILURE_THRESHOLD"],
158
+ resetTimeoutMs: Bun.env["CB_RESET_TIMEOUT_MS"],
159
+ halfOpenSuccessThreshold: Bun.env["CB_HALF_OPEN_SUCCESS_THRESHOLD"],
160
+ },
161
+ passwordPolicy: {
162
+ minLength: Bun.env["PASSWORD_MIN_LENGTH"],
163
+ requireUppercase: Bun.env["PASSWORD_REQUIRE_UPPERCASE"],
164
+ requireLowercase: Bun.env["PASSWORD_REQUIRE_LOWERCASE"],
165
+ requireDigit: Bun.env["PASSWORD_REQUIRE_DIGIT"],
166
+ requireSpecial: Bun.env["PASSWORD_REQUIRE_SPECIAL"],
167
+ historyCount: Bun.env["PASSWORD_HISTORY_COUNT"],
168
+ maxAgeDays: Bun.env["PASSWORD_MAX_AGE_DAYS"],
169
+ },
170
+ oauth: {
171
+ googleClientId: Bun.env["OAUTH_GOOGLE_CLIENT_ID"],
172
+ googleClientSecret: Bun.env["OAUTH_GOOGLE_CLIENT_SECRET"],
173
+ githubClientId: Bun.env["OAUTH_GITHUB_CLIENT_ID"],
174
+ githubClientSecret: Bun.env["OAUTH_GITHUB_CLIENT_SECRET"],
175
+ },
176
+ });
177
+
178
+ if (!result.success) {
179
+ const formatted = result.error.flatten();
180
+ printConfigError(formatted.fieldErrors as Record<string, string[]>);
181
+ process.exit(1);
182
+ }
183
+
184
+ return result.data;
185
+ };
@@ -0,0 +1 @@
1
+ export { type AppConfig, loadConfig } from "./config.js";