@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,58 @@
1
+ export { loadConfig, type AppConfig } from "./config/index.js";
2
+ export { createLogger } from "./logging/index.js";
3
+ export {
4
+ createPasswordHasher,
5
+ createTokenService,
6
+ createInMemoryTokenBlacklist,
7
+ createInMemoryAccountLockout,
8
+ createTotpService,
9
+ createPasswordPolicy,
10
+ } from "./security/index.js";
11
+ export {
12
+ createInMemoryUserRepository,
13
+ createSqliteUserRepository,
14
+ createSqliteTokenBlacklist,
15
+ createSqliteAccountLockout,
16
+ createSqliteAuditLog,
17
+ createSqliteVerificationTokenRepo,
18
+ createSqliteRefreshTokenStore,
19
+ createSqliteApiKeyRepository,
20
+ createSqlitePasswordHistory,
21
+ createSqliteOAuthAccountRepo,
22
+ migrateUp,
23
+ migrateDown,
24
+ } from "./database/index.js";
25
+ export { createMetricsCollector } from "./metrics/index.js";
26
+ export {
27
+ createCircuitBreaker,
28
+ CircuitBreakerOpenError,
29
+ createRetryPolicy,
30
+ } from "./resilience/index.js";
31
+ export { createWebhookAlertSink, createNoopAlertSink } from "./alerting/index.js";
32
+ export {
33
+ resolveTraceContext,
34
+ formatTraceparent,
35
+ type TraceContext,
36
+ } from "./tracing/index.js";
37
+ export { createGoogleOAuthProvider, createGitHubOAuthProvider } from "./oauth/index.js";
38
+ export {
39
+ createEventBus,
40
+ createDomainEventFactory,
41
+ createWebhookDispatcher,
42
+ } from "./events/index.js";
43
+ export { createInMemoryWebhookRegistry } from "./events/in-memory-webhook-registry.js";
44
+ export { createInMemoryJobQueue } from "./jobs/index.js";
45
+ export {
46
+ createPgUserRepository,
47
+ createPgTokenBlacklist,
48
+ createPgAccountLockout,
49
+ createPgAuditLog,
50
+ createPgVerificationTokenRepo,
51
+ createPgRefreshTokenStore,
52
+ createPgApiKeyRepository,
53
+ createPgPasswordHistory,
54
+ createPgOAuthAccountRepo,
55
+ pgMigrateUp,
56
+ pgMigrateDown,
57
+ } from "./database/index.js";
58
+ export { createInMemoryCache, createRedisCache } from "./cache/index.js";
@@ -0,0 +1 @@
1
+ export { createInMemoryJobQueue } from "./job-queue.js";
@@ -0,0 +1,185 @@
1
+ /**
2
+ * In-memory background job queue with retry and exponential backoff.
3
+ *
4
+ * Uses a polling loop with configurable interval. For production,
5
+ * swap with a Redis/SQLite-backed adapter.
6
+ */
7
+
8
+ import type { AppError } from "../../core/errors/app-error.js";
9
+ import { internal } from "../../core/errors/app-error.js";
10
+ import {
11
+ type Job,
12
+ type JobHandler,
13
+ type JobQueue,
14
+ type JobQueueStats,
15
+ JobStatus,
16
+ type SubmitJobOptions,
17
+ } from "../../core/ports/job-queue.js";
18
+ import type { Logger } from "../../core/ports/logger.js";
19
+ import type { Result } from "../../core/types/result.js";
20
+ import { err, ok } from "../../core/types/result.js";
21
+ import { generateId } from "../../shared/utils/id.js";
22
+
23
+ interface JobQueueDeps {
24
+ readonly logger: Logger;
25
+ /** Polling interval in ms (default: 1000) */
26
+ readonly pollIntervalMs?: number | undefined;
27
+ }
28
+
29
+ export const createInMemoryJobQueue = (deps: JobQueueDeps): JobQueue => {
30
+ const { logger } = deps;
31
+ const pollIntervalMs = deps.pollIntervalMs ?? 1_000;
32
+
33
+ const jobs = new Map<string, Job>();
34
+ const handlers = new Map<string, JobHandler>();
35
+ let timer: ReturnType<typeof setInterval> | null = null;
36
+ let running = false;
37
+
38
+ const updateJob = (id: string, updates: Partial<Job>): void => {
39
+ const job = jobs.get(id);
40
+ if (job) {
41
+ jobs.set(id, { ...job, ...updates, updatedAt: new Date().toISOString() } as Job);
42
+ }
43
+ };
44
+
45
+ const processJob = async (job: Job): Promise<void> => {
46
+ const handler = handlers.get(job.type);
47
+ if (!handler) {
48
+ logger.warn("No handler registered for job type", { type: job.type, jobId: job.id });
49
+ updateJob(job.id, { status: JobStatus.DEAD, lastError: `No handler for type: ${job.type}` });
50
+ return;
51
+ }
52
+
53
+ updateJob(job.id, { status: JobStatus.RUNNING, attempts: job.attempts + 1 });
54
+
55
+ try {
56
+ await handler(job.payload);
57
+ updateJob(job.id, { status: JobStatus.COMPLETED });
58
+ logger.debug("Job completed", { jobId: job.id, type: job.type });
59
+ } catch (e: unknown) {
60
+ const errorMsg = e instanceof Error ? e.message : String(e);
61
+ const newAttempts = job.attempts + 1;
62
+
63
+ if (newAttempts >= job.maxRetries) {
64
+ updateJob(job.id, { status: JobStatus.DEAD, lastError: errorMsg });
65
+ logger.error("Job failed permanently", {
66
+ jobId: job.id,
67
+ type: job.type,
68
+ attempts: newAttempts,
69
+ error: errorMsg,
70
+ });
71
+ } else {
72
+ // Exponential backoff: 1s, 2s, 4s, 8s, ... capped at 60s
73
+ const backoffMs = Math.min(1_000 * 2 ** newAttempts, 60_000);
74
+ const runAt = new Date(Date.now() + backoffMs).toISOString();
75
+ updateJob(job.id, {
76
+ status: JobStatus.PENDING,
77
+ lastError: errorMsg,
78
+ runAt,
79
+ });
80
+ logger.warn("Job failed, retrying", {
81
+ jobId: job.id,
82
+ type: job.type,
83
+ attempts: newAttempts,
84
+ nextRunAt: runAt,
85
+ error: errorMsg,
86
+ });
87
+ }
88
+ }
89
+ };
90
+
91
+ const poll = (): void => {
92
+ const now = new Date().toISOString();
93
+ for (const job of jobs.values()) {
94
+ if (job.status === JobStatus.PENDING && job.runAt <= now) {
95
+ // Fire and forget — errors are handled inside processJob
96
+ processJob(job);
97
+ }
98
+ }
99
+ };
100
+
101
+ return {
102
+ submit(options: SubmitJobOptions): Result<Job, AppError> {
103
+ try {
104
+ const now = new Date();
105
+ const runAt = options.delayMs
106
+ ? new Date(now.getTime() + options.delayMs).toISOString()
107
+ : now.toISOString();
108
+
109
+ const job: Job = {
110
+ id: generateId(),
111
+ type: options.type,
112
+ payload: options.payload,
113
+ status: JobStatus.PENDING,
114
+ attempts: 0,
115
+ maxRetries: options.maxRetries ?? 3,
116
+ runAt,
117
+ createdAt: now.toISOString(),
118
+ updatedAt: now.toISOString(),
119
+ };
120
+
121
+ jobs.set(job.id, job);
122
+ logger.debug("Job submitted", { jobId: job.id, type: job.type, runAt });
123
+ return ok(job);
124
+ } catch (e: unknown) {
125
+ return err(internal(e instanceof Error ? e.message : "Failed to submit job"));
126
+ }
127
+ },
128
+
129
+ registerHandler(type: string, handler: JobHandler): void {
130
+ handlers.set(type, handler);
131
+ logger.debug("Job handler registered", { type });
132
+ },
133
+
134
+ start(): void {
135
+ if (running) return;
136
+ running = true;
137
+ timer = setInterval(poll, pollIntervalMs);
138
+ logger.info("Job queue started", { pollIntervalMs });
139
+ },
140
+
141
+ stop(): void {
142
+ if (!running) return;
143
+ running = false;
144
+ if (timer) {
145
+ clearInterval(timer);
146
+ timer = null;
147
+ }
148
+ logger.info("Job queue stopped");
149
+ },
150
+
151
+ getJob(id: string): Result<Job | null, AppError> {
152
+ return ok(jobs.get(id) ?? null);
153
+ },
154
+
155
+ stats(): Result<JobQueueStats, AppError> {
156
+ let pending = 0;
157
+ let jobRunning = 0;
158
+ let completed = 0;
159
+ let failed = 0;
160
+ let dead = 0;
161
+
162
+ for (const job of jobs.values()) {
163
+ switch (job.status) {
164
+ case JobStatus.PENDING:
165
+ pending++;
166
+ break;
167
+ case JobStatus.RUNNING:
168
+ jobRunning++;
169
+ break;
170
+ case JobStatus.COMPLETED:
171
+ completed++;
172
+ break;
173
+ case JobStatus.FAILED:
174
+ failed++;
175
+ break;
176
+ case JobStatus.DEAD:
177
+ dead++;
178
+ break;
179
+ }
180
+ }
181
+
182
+ return ok({ pending, running: jobRunning, completed, failed, dead });
183
+ },
184
+ };
185
+ };
@@ -0,0 +1 @@
1
+ export { createLogger, type LogFormat } from "./logger.js";
@@ -0,0 +1,63 @@
1
+ import type { LogLevel, Logger } from "../../core/ports/logger.js";
2
+ import { formatLogEntry } from "../../shared/log-format.js";
3
+
4
+ const LEVEL_PRIORITY: Record<LogLevel, number> = {
5
+ debug: 0,
6
+ info: 1,
7
+ warn: 2,
8
+ error: 3,
9
+ fatal: 4,
10
+ };
11
+
12
+ export type LogFormat = "pretty" | "json";
13
+
14
+ /**
15
+ * Format a log entry as structured JSON (for Datadog, ELK, CloudWatch, etc.).
16
+ */
17
+ const formatJsonEntry = (level: LogLevel, msg: string, meta: Record<string, unknown>): string => {
18
+ const entry: Record<string, unknown> = {
19
+ level,
20
+ msg,
21
+ time: new Date().toISOString(),
22
+ ...meta,
23
+ };
24
+ return `${JSON.stringify(entry)}\n`;
25
+ };
26
+
27
+ /**
28
+ * Logger — zero dependencies.
29
+ * Supports two modes:
30
+ * - "pretty": ANSI-colored human-readable output (default, for development)
31
+ * - "json": structured JSON lines (for production log aggregators)
32
+ */
33
+ export const createLogger = (
34
+ minLevel: LogLevel = "info",
35
+ bindings: Record<string, unknown> = {},
36
+ format: LogFormat = "pretty",
37
+ ): Logger => {
38
+ const minPriority = LEVEL_PRIORITY[minLevel];
39
+
40
+ const formatter = format === "json" ? formatJsonEntry : formatLogEntry;
41
+
42
+ const write = (level: LogLevel, msg: string, meta?: Record<string, unknown>): void => {
43
+ if (LEVEL_PRIORITY[level] < minPriority) return;
44
+
45
+ const allMeta = { ...bindings, ...meta };
46
+ const line = formatter(level, msg, allMeta);
47
+
48
+ if (LEVEL_PRIORITY[level] >= LEVEL_PRIORITY.warn) {
49
+ process.stderr.write(line);
50
+ } else {
51
+ process.stdout.write(line);
52
+ }
53
+ };
54
+
55
+ return {
56
+ debug: (msg, meta) => write("debug", msg, meta),
57
+ info: (msg, meta) => write("info", msg, meta),
58
+ warn: (msg, meta) => write("warn", msg, meta),
59
+ error: (msg, meta) => write("error", msg, meta),
60
+ fatal: (msg, meta) => write("fatal", msg, meta),
61
+ child: (extra) => createLogger(minLevel, { ...bindings, ...extra }, format),
62
+ };
63
+ };
@@ -0,0 +1 @@
1
+ export { createMetricsCollector } from "./prometheus.js";
@@ -0,0 +1,231 @@
1
+ /**
2
+ * Prometheus-compatible metrics collector — zero dependencies.
3
+ *
4
+ * Implements counters, histograms, and gauges with label support.
5
+ * Serializes to Prometheus text exposition format (v0.0.4).
6
+ */
7
+
8
+ import type {
9
+ Counter,
10
+ Gauge,
11
+ Histogram,
12
+ HistogramSnapshot,
13
+ MetricsCollector,
14
+ } from "../../core/ports/metrics.js";
15
+
16
+ // ── Label key serialization ──
17
+
18
+ const labelKey = (labels?: Record<string, string>): string => {
19
+ if (!labels) return "";
20
+ const entries = Object.entries(labels).sort(([a], [b]) => (a < b ? -1 : 1));
21
+ if (entries.length === 0) return "";
22
+ return entries.map(([k, v]) => `${k}="${v}"`).join(",");
23
+ };
24
+
25
+ const formatLabels = (key: string): string => (key ? `{${key}}` : "");
26
+
27
+ // ── Counter implementation ──
28
+
29
+ const createCounter = (name: string, help: string): Counter & { serialize(): string } => {
30
+ const values = new Map<string, number>();
31
+
32
+ return {
33
+ inc(labels?: Record<string, string>, value = 1) {
34
+ const key = labelKey(labels);
35
+ values.set(key, (values.get(key) ?? 0) + value);
36
+ },
37
+ get(labels?: Record<string, string>): number {
38
+ return values.get(labelKey(labels)) ?? 0;
39
+ },
40
+ serialize(): string {
41
+ const lines: string[] = [];
42
+ lines.push(`# HELP ${name} ${help}`);
43
+ lines.push(`# TYPE ${name} counter`);
44
+ for (const [key, value] of values) {
45
+ lines.push(`${name}${formatLabels(key)} ${value}`);
46
+ }
47
+ // If no values recorded, emit a zero line
48
+ if (values.size === 0) {
49
+ lines.push(`${name} 0`);
50
+ }
51
+ return lines.join("\n");
52
+ },
53
+ };
54
+ };
55
+
56
+ // ── Histogram implementation ──
57
+
58
+ /** Default Prometheus-style buckets (in ms for latency) */
59
+ const DEFAULT_BUCKETS = [1, 5, 10, 25, 50, 100, 250, 500, 1000, 2500, 5000, 10000];
60
+
61
+ interface HistogramData {
62
+ count: number;
63
+ sum: number;
64
+ buckets: Map<number, number>;
65
+ }
66
+
67
+ const createHistogram = (
68
+ name: string,
69
+ help: string,
70
+ buckets: readonly number[] = DEFAULT_BUCKETS,
71
+ ): Histogram & { serialize(): string } => {
72
+ const data = new Map<string, HistogramData>();
73
+
74
+ const getOrCreate = (key: string): HistogramData => {
75
+ let entry = data.get(key);
76
+ if (!entry) {
77
+ entry = {
78
+ count: 0,
79
+ sum: 0,
80
+ buckets: new Map(buckets.map((b) => [b, 0])),
81
+ };
82
+ data.set(key, entry);
83
+ }
84
+ return entry;
85
+ };
86
+
87
+ return {
88
+ observe(value: number, labels?: Record<string, string>) {
89
+ const key = labelKey(labels);
90
+ const entry = getOrCreate(key);
91
+ entry.count++;
92
+ entry.sum += value;
93
+ for (const bound of buckets) {
94
+ if (value <= bound) {
95
+ entry.buckets.set(bound, (entry.buckets.get(bound) ?? 0) + 1);
96
+ }
97
+ }
98
+ },
99
+ get(labels?: Record<string, string>): HistogramSnapshot | undefined {
100
+ const entry = data.get(labelKey(labels));
101
+ if (!entry) return undefined;
102
+ return {
103
+ count: entry.count,
104
+ sum: entry.sum,
105
+ buckets: new Map(entry.buckets),
106
+ };
107
+ },
108
+ serialize(): string {
109
+ const lines: string[] = [];
110
+ lines.push(`# HELP ${name} ${help}`);
111
+ lines.push(`# TYPE ${name} histogram`);
112
+
113
+ for (const [key, entry] of data) {
114
+ const lbl = key ? `,${key}` : "";
115
+ for (const bound of buckets) {
116
+ const bucketCount = entry.buckets.get(bound) ?? 0;
117
+ lines.push(`${name}_bucket{le="${bound}"${lbl}} ${bucketCount}`);
118
+ }
119
+ lines.push(`${name}_bucket{le="+Inf"${lbl}} ${entry.count}`);
120
+ lines.push(`${name}_sum${formatLabels(key)} ${entry.sum}`);
121
+ lines.push(`${name}_count${formatLabels(key)} ${entry.count}`);
122
+ }
123
+
124
+ if (data.size === 0) {
125
+ // Emit empty buckets
126
+ for (const bound of buckets) {
127
+ lines.push(`${name}_bucket{le="${bound}"} 0`);
128
+ }
129
+ lines.push(`${name}_bucket{le="+Inf"} 0`);
130
+ lines.push(`${name}_sum 0`);
131
+ lines.push(`${name}_count 0`);
132
+ }
133
+
134
+ return lines.join("\n");
135
+ },
136
+ };
137
+ };
138
+
139
+ // ── Gauge implementation ──
140
+
141
+ const createGauge = (name: string, help: string): Gauge & { serialize(): string } => {
142
+ const values = new Map<string, number>();
143
+
144
+ return {
145
+ set(value: number, labels?: Record<string, string>) {
146
+ values.set(labelKey(labels), value);
147
+ },
148
+ inc(labels?: Record<string, string>, value = 1) {
149
+ const key = labelKey(labels);
150
+ values.set(key, (values.get(key) ?? 0) + value);
151
+ },
152
+ dec(labels?: Record<string, string>, value = 1) {
153
+ const key = labelKey(labels);
154
+ values.set(key, (values.get(key) ?? 0) - value);
155
+ },
156
+ get(labels?: Record<string, string>): number {
157
+ return values.get(labelKey(labels)) ?? 0;
158
+ },
159
+ serialize(): string {
160
+ const lines: string[] = [];
161
+ lines.push(`# HELP ${name} ${help}`);
162
+ lines.push(`# TYPE ${name} gauge`);
163
+ for (const [key, value] of values) {
164
+ lines.push(`${name}${formatLabels(key)} ${value}`);
165
+ }
166
+ if (values.size === 0) {
167
+ lines.push(`${name} 0`);
168
+ }
169
+ return lines.join("\n");
170
+ },
171
+ };
172
+ };
173
+
174
+ // ── Metrics collector factory ──
175
+
176
+ export const createMetricsCollector = (): MetricsCollector => {
177
+ const httpRequestsTotal = createCounter("http_requests_total", "Total number of HTTP requests");
178
+ const httpRequestDurationMs = createHistogram(
179
+ "http_request_duration_ms",
180
+ "HTTP request duration in milliseconds",
181
+ );
182
+ const httpActiveConnections = createGauge(
183
+ "http_active_connections",
184
+ "Number of currently active HTTP connections",
185
+ );
186
+ const httpErrorsTotal = createCounter(
187
+ "http_errors_total",
188
+ "Total number of HTTP error responses (4xx + 5xx)",
189
+ );
190
+ const circuitBreakerState = createGauge(
191
+ "circuit_breaker_state",
192
+ "Circuit breaker state (0=closed, 1=half_open, 2=open)",
193
+ );
194
+ const alertsSentTotal = createCounter(
195
+ "alerts_sent_total",
196
+ "Total number of alert notifications sent",
197
+ );
198
+
199
+ return {
200
+ httpRequestsTotal,
201
+ httpRequestDurationMs,
202
+ httpActiveConnections,
203
+ httpErrorsTotal,
204
+ circuitBreakerState,
205
+ alertsSentTotal,
206
+
207
+ serialize(): string {
208
+ const sections = [
209
+ httpRequestsTotal.serialize(),
210
+ httpRequestDurationMs.serialize(),
211
+ httpActiveConnections.serialize(),
212
+ httpErrorsTotal.serialize(),
213
+ circuitBreakerState.serialize(),
214
+ alertsSentTotal.serialize(),
215
+ ];
216
+ return `${sections.join("\n\n")}\n`;
217
+ },
218
+
219
+ reset(): void {
220
+ // Re-create would be cleanest but we just reset the underlying maps
221
+ // For simplicity, we expose reset through the collector
222
+ const collector = createMetricsCollector();
223
+ Object.assign(httpRequestsTotal, collector.httpRequestsTotal);
224
+ Object.assign(httpRequestDurationMs, collector.httpRequestDurationMs);
225
+ Object.assign(httpActiveConnections, collector.httpActiveConnections);
226
+ Object.assign(httpErrorsTotal, collector.httpErrorsTotal);
227
+ Object.assign(circuitBreakerState, collector.circuitBreakerState);
228
+ Object.assign(alertsSentTotal, collector.alertsSentTotal);
229
+ },
230
+ };
231
+ };
@@ -0,0 +1,116 @@
1
+ import { type AppError, internal, unauthorized } from "../../core/errors/app-error.js";
2
+ import type { OAuthProvider, OAuthUserInfo } from "../../core/ports/oauth.js";
3
+ import { type Result, err, ok } from "../../core/types/result.js";
4
+
5
+ /**
6
+ * GitHub OAuth2 provider adapter.
7
+ * Uses GitHub's OAuth App endpoints for authorization code flow.
8
+ * Zero external dependencies — uses native fetch.
9
+ */
10
+
11
+ interface GitHubOAuthConfig {
12
+ readonly clientId: string;
13
+ readonly clientSecret: string;
14
+ }
15
+
16
+ export const createGitHubOAuthProvider = (config: GitHubOAuthConfig): OAuthProvider => ({
17
+ name: "github",
18
+
19
+ getAuthorizationUrl(state: string, redirectUri: string): string {
20
+ const params = new URLSearchParams({
21
+ client_id: config.clientId,
22
+ redirect_uri: redirectUri,
23
+ scope: "user:email",
24
+ state,
25
+ });
26
+ return `https://github.com/login/oauth/authorize?${params.toString()}`;
27
+ },
28
+
29
+ // biome-ignore lint/complexity/noExcessiveCognitiveComplexity: OAuth code exchange is inherently complex
30
+ async exchangeCode(code: string, redirectUri: string): Promise<Result<OAuthUserInfo, AppError>> {
31
+ try {
32
+ // Exchange authorization code for access token
33
+ const tokenRes = await fetch("https://github.com/login/oauth/access_token", {
34
+ method: "POST",
35
+ headers: {
36
+ "Content-Type": "application/json",
37
+ Accept: "application/json",
38
+ },
39
+ body: JSON.stringify({
40
+ client_id: config.clientId,
41
+ client_secret: config.clientSecret,
42
+ code,
43
+ redirect_uri: redirectUri,
44
+ }),
45
+ });
46
+
47
+ if (!tokenRes.ok) {
48
+ return err(unauthorized("Failed to exchange authorization code with GitHub"));
49
+ }
50
+
51
+ const tokenData = (await tokenRes.json()) as {
52
+ access_token?: string;
53
+ error?: string;
54
+ };
55
+ if (tokenData.error || !tokenData.access_token) {
56
+ return err(unauthorized(tokenData.error ?? "No access token in GitHub response"));
57
+ }
58
+
59
+ // Fetch user info
60
+ const userRes = await fetch("https://api.github.com/user", {
61
+ headers: {
62
+ Authorization: `Bearer ${tokenData.access_token}`,
63
+ Accept: "application/vnd.github+json",
64
+ "User-Agent": "onlyApi",
65
+ },
66
+ });
67
+
68
+ if (!userRes.ok) {
69
+ return err(unauthorized("Failed to fetch GitHub user info"));
70
+ }
71
+
72
+ const userData = (await userRes.json()) as {
73
+ id?: number;
74
+ login?: string;
75
+ name?: string;
76
+ };
77
+
78
+ if (!userData.id) {
79
+ return err(unauthorized("Incomplete GitHub user info"));
80
+ }
81
+
82
+ // Fetch primary email (may not be public)
83
+ let email: string | undefined;
84
+ const emailRes = await fetch("https://api.github.com/user/emails", {
85
+ headers: {
86
+ Authorization: `Bearer ${tokenData.access_token}`,
87
+ Accept: "application/vnd.github+json",
88
+ "User-Agent": "onlyApi",
89
+ },
90
+ });
91
+
92
+ if (emailRes.ok) {
93
+ const emails = (await emailRes.json()) as Array<{
94
+ email: string;
95
+ primary: boolean;
96
+ verified: boolean;
97
+ }>;
98
+ const primary = emails.find((e) => e.primary && e.verified);
99
+ email = primary?.email;
100
+ }
101
+
102
+ if (!email) {
103
+ return err(unauthorized("No verified email found on GitHub account"));
104
+ }
105
+
106
+ const displayName = userData.name ?? userData.login;
107
+ return ok({
108
+ providerId: String(userData.id),
109
+ email,
110
+ ...(displayName ? { name: displayName } : {}),
111
+ });
112
+ } catch (e: unknown) {
113
+ return err(internal("GitHub OAuth exchange failed", e));
114
+ }
115
+ },
116
+ });