@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,118 @@
1
+ /**
2
+ * SQL Server OAuth account repository adapter.
3
+ */
4
+
5
+ import type sql from "mssql";
6
+ import { conflict, internal, notFound } from "../../../core/errors/app-error.js";
7
+ import type { AppError } from "../../../core/errors/app-error.js";
8
+ import type { OAuthAccount, OAuthAccountRepository } from "../../../core/ports/oauth.js";
9
+ import type { UserId } from "../../../core/types/brand.js";
10
+ import { brand } from "../../../core/types/brand.js";
11
+ import { type Result, err, ok } from "../../../core/types/result.js";
12
+ import { generateId } from "../../../shared/utils/id.js";
13
+
14
+ const toOAuthAccount = (row: Record<string, unknown>): OAuthAccount => ({
15
+ id: row["id"] as string,
16
+ userId: brand<string, "UserId">(row["user_id"] as string),
17
+ provider: row["provider"] as string,
18
+ providerUserId: row["provider_user_id"] as string,
19
+ email: (row["email"] as string) ?? null,
20
+ createdAt: Number(row["created_at"]),
21
+ });
22
+
23
+ export const createMssqlOAuthAccountRepo = (pool: sql.ConnectionPool): OAuthAccountRepository => ({
24
+ async link(
25
+ userId: UserId,
26
+ provider: string,
27
+ providerUserId: string,
28
+ email: string | null,
29
+ ): Promise<Result<OAuthAccount, AppError>> {
30
+ try {
31
+ const id = generateId();
32
+ const now = Date.now();
33
+
34
+ try {
35
+ await pool
36
+ .request()
37
+ .input("id", id)
38
+ .input("userId", userId as string)
39
+ .input("provider", provider)
40
+ .input("providerUserId", providerUserId)
41
+ .input("email", email)
42
+ .input("createdAt", now)
43
+ .query(`
44
+ INSERT INTO oauth_accounts (id, user_id, provider, provider_user_id, email, created_at)
45
+ VALUES (@id, @userId, @provider, @providerUserId, @email, @createdAt)
46
+ `);
47
+ } catch (e: unknown) {
48
+ if (
49
+ e instanceof Error &&
50
+ (e.message.includes("duplicate key") || e.message.includes("UNIQUE"))
51
+ ) {
52
+ return err(conflict("OAuth account already linked"));
53
+ }
54
+ throw e;
55
+ }
56
+
57
+ return ok({
58
+ id,
59
+ userId,
60
+ provider,
61
+ providerUserId,
62
+ email,
63
+ createdAt: now,
64
+ });
65
+ } catch (e: unknown) {
66
+ return err(internal("Database error", e));
67
+ }
68
+ },
69
+
70
+ async findByProvider(
71
+ provider: string,
72
+ providerUserId: string,
73
+ ): Promise<Result<OAuthAccount | null, AppError>> {
74
+ try {
75
+ const result = await pool
76
+ .request()
77
+ .input("provider", provider)
78
+ .input("providerUserId", providerUserId)
79
+ .query(`
80
+ SELECT * FROM oauth_accounts
81
+ WHERE provider = @provider AND provider_user_id = @providerUserId
82
+ `);
83
+ if (result.recordset.length === 0) return ok(null);
84
+ return ok(toOAuthAccount(result.recordset[0] as Record<string, unknown>));
85
+ } catch (e: unknown) {
86
+ return err(internal("Database error", e));
87
+ }
88
+ },
89
+
90
+ async listByUser(userId: UserId): Promise<Result<readonly OAuthAccount[], AppError>> {
91
+ try {
92
+ const result = await pool
93
+ .request()
94
+ .input("userId", userId as string)
95
+ .query("SELECT * FROM oauth_accounts WHERE user_id = @userId ORDER BY created_at DESC");
96
+ return ok(result.recordset.map((r: Record<string, unknown>) => toOAuthAccount(r)));
97
+ } catch (e: unknown) {
98
+ return err(internal("Database error", e));
99
+ }
100
+ },
101
+
102
+ async unlink(id: string, userId: UserId): Promise<Result<void, AppError>> {
103
+ try {
104
+ const result = await pool
105
+ .request()
106
+ .input("id", id)
107
+ .input("userId", userId as string)
108
+ .query("SELECT id FROM oauth_accounts WHERE id = @id AND user_id = @userId");
109
+
110
+ if (result.recordset.length === 0) return err(notFound("OAuth account"));
111
+
112
+ await pool.request().input("id", id).query("DELETE FROM oauth_accounts WHERE id = @id");
113
+ return ok(undefined);
114
+ } catch (e: unknown) {
115
+ return err(internal("Database error", e));
116
+ }
117
+ },
118
+ });
@@ -0,0 +1,71 @@
1
+ /**
2
+ * SQL Server password history adapter.
3
+ */
4
+
5
+ import type sql from "mssql";
6
+ import { internal } from "../../../core/errors/app-error.js";
7
+ import type { AppError } from "../../../core/errors/app-error.js";
8
+ import type { PasswordHistory } from "../../../core/ports/password-history.js";
9
+ import type { UserId } from "../../../core/types/brand.js";
10
+ import { type Result, err, ok } from "../../../core/types/result.js";
11
+ import { generateId } from "../../../shared/utils/id.js";
12
+
13
+ export const createMssqlPasswordHistory = (pool: sql.ConnectionPool): PasswordHistory => ({
14
+ async add(userId: UserId, passwordHash: string): Promise<Result<void, AppError>> {
15
+ try {
16
+ const id = generateId();
17
+ await pool
18
+ .request()
19
+ .input("id", id)
20
+ .input("userId", userId as string)
21
+ .input("passwordHash", passwordHash)
22
+ .input("createdAt", Date.now())
23
+ .query(`
24
+ INSERT INTO password_history (id, user_id, password_hash, created_at)
25
+ VALUES (@id, @userId, @passwordHash, @createdAt)
26
+ `);
27
+ return ok(undefined);
28
+ } catch (e: unknown) {
29
+ return err(internal("Database error", e));
30
+ }
31
+ },
32
+
33
+ async getRecent(userId: UserId, count: number): Promise<Result<readonly string[], AppError>> {
34
+ try {
35
+ const result = await pool
36
+ .request()
37
+ .input("userId", userId as string)
38
+ .input("count", count)
39
+ .query(`
40
+ SELECT TOP (@count) password_hash FROM password_history
41
+ WHERE user_id = @userId
42
+ ORDER BY created_at DESC
43
+ `);
44
+ return ok(result.recordset.map((r: { password_hash: string }) => r.password_hash));
45
+ } catch (e: unknown) {
46
+ return err(internal("Database error", e));
47
+ }
48
+ },
49
+
50
+ async prune(userId: UserId, keepCount: number): Promise<Result<void, AppError>> {
51
+ try {
52
+ // Delete all but the most recent `keepCount` entries
53
+ await pool
54
+ .request()
55
+ .input("userId", userId as string)
56
+ .input("keepCount", keepCount)
57
+ .query(`
58
+ DELETE FROM password_history
59
+ WHERE user_id = @userId
60
+ AND id NOT IN (
61
+ SELECT TOP (@keepCount) id FROM password_history
62
+ WHERE user_id = @userId
63
+ ORDER BY created_at DESC
64
+ )
65
+ `);
66
+ return ok(undefined);
67
+ } catch (e: unknown) {
68
+ return err(internal("Database error", e));
69
+ }
70
+ },
71
+ });
@@ -0,0 +1,144 @@
1
+ /**
2
+ * SQL Server refresh token store adapter.
3
+ */
4
+
5
+ import type sql from "mssql";
6
+ import { conflict, internal, notFound } from "../../../core/errors/app-error.js";
7
+ import type { AppError } from "../../../core/errors/app-error.js";
8
+ import type {
9
+ RefreshTokenFamily,
10
+ RefreshTokenStore,
11
+ } from "../../../core/ports/refresh-token-store.js";
12
+ import type { UserId } from "../../../core/types/brand.js";
13
+ import { brand } from "../../../core/types/brand.js";
14
+ import { type Result, err, ok } from "../../../core/types/result.js";
15
+ import { generateId } from "../../../shared/utils/id.js";
16
+
17
+ const toFamily = (row: Record<string, unknown>): RefreshTokenFamily => ({
18
+ id: row["id"] as string,
19
+ userId: brand<string, "UserId">(row["user_id"] as string),
20
+ currentTokenHash: row["current_token_hash"] as string,
21
+ revoked: Boolean(row["revoked"]),
22
+ createdAt: Number(row["created_at"]),
23
+ updatedAt: Number(row["updated_at"]),
24
+ });
25
+
26
+ export const createMssqlRefreshTokenStore = (pool: sql.ConnectionPool): RefreshTokenStore => ({
27
+ async createFamily(userId: UserId, tokenHash: string): Promise<Result<string, AppError>> {
28
+ try {
29
+ const id = generateId();
30
+ const now = Date.now();
31
+ await pool
32
+ .request()
33
+ .input("id", id)
34
+ .input("userId", userId as string)
35
+ .input("tokenHash", tokenHash)
36
+ .input("now", now)
37
+ .query(`
38
+ INSERT INTO refresh_token_families (id, user_id, current_token_hash, revoked, created_at, updated_at)
39
+ VALUES (@id, @userId, @tokenHash, 0, @now, @now)
40
+ `);
41
+ return ok(id);
42
+ } catch (e: unknown) {
43
+ return err(internal("Database error", e));
44
+ }
45
+ },
46
+
47
+ async rotate(
48
+ familyId: string,
49
+ oldTokenHash: string,
50
+ newTokenHash: string,
51
+ ): Promise<Result<void, AppError>> {
52
+ try {
53
+ const result = await pool
54
+ .request()
55
+ .input("familyId", familyId)
56
+ .query("SELECT * FROM refresh_token_families WHERE id = @familyId AND revoked = 0");
57
+
58
+ if (result.recordset.length === 0) return err(notFound("Refresh token family"));
59
+
60
+ const family = result.recordset[0];
61
+ if (family.current_token_hash !== oldTokenHash) {
62
+ // Reuse detected — revoke entire family
63
+ await pool
64
+ .request()
65
+ .input("now", Date.now())
66
+ .input("familyId", familyId)
67
+ .query(
68
+ "UPDATE refresh_token_families SET revoked = 1, updated_at = @now WHERE id = @familyId",
69
+ );
70
+ return err(conflict("Token reuse detected"));
71
+ }
72
+
73
+ await pool
74
+ .request()
75
+ .input("newTokenHash", newTokenHash)
76
+ .input("now", Date.now())
77
+ .input("familyId", familyId)
78
+ .query(`
79
+ UPDATE refresh_token_families
80
+ SET current_token_hash = @newTokenHash, updated_at = @now
81
+ WHERE id = @familyId
82
+ `);
83
+ return ok(undefined);
84
+ } catch (e: unknown) {
85
+ return err(internal("Database error", e));
86
+ }
87
+ },
88
+
89
+ async findByTokenHash(tokenHash: string): Promise<Result<RefreshTokenFamily | null, AppError>> {
90
+ try {
91
+ const result = await pool
92
+ .request()
93
+ .input("tokenHash", tokenHash)
94
+ .query("SELECT * FROM refresh_token_families WHERE current_token_hash = @tokenHash");
95
+ if (result.recordset.length === 0) return ok(null);
96
+ return ok(toFamily(result.recordset[0] as Record<string, unknown>));
97
+ } catch (e: unknown) {
98
+ return err(internal("Database error", e));
99
+ }
100
+ },
101
+
102
+ async revokeFamily(familyId: string): Promise<Result<void, AppError>> {
103
+ try {
104
+ await pool
105
+ .request()
106
+ .input("now", Date.now())
107
+ .input("familyId", familyId)
108
+ .query(
109
+ "UPDATE refresh_token_families SET revoked = 1, updated_at = @now WHERE id = @familyId",
110
+ );
111
+ return ok(undefined);
112
+ } catch (e: unknown) {
113
+ return err(internal("Database error", e));
114
+ }
115
+ },
116
+
117
+ async revokeAllForUser(userId: UserId): Promise<Result<void, AppError>> {
118
+ try {
119
+ await pool
120
+ .request()
121
+ .input("now", Date.now())
122
+ .input("userId", userId as string)
123
+ .query(
124
+ "UPDATE refresh_token_families SET revoked = 1, updated_at = @now WHERE user_id = @userId",
125
+ );
126
+ return ok(undefined);
127
+ } catch (e: unknown) {
128
+ return err(internal("Database error", e));
129
+ }
130
+ },
131
+
132
+ async prune(maxAgeMs: number): Promise<Result<number, AppError>> {
133
+ try {
134
+ const cutoff = Date.now() - maxAgeMs;
135
+ const result = await pool
136
+ .request()
137
+ .input("cutoff", cutoff)
138
+ .query("DELETE FROM refresh_token_families WHERE revoked = 1 AND updated_at < @cutoff");
139
+ return ok(result.rowsAffected[0] ?? 0);
140
+ } catch (e: unknown) {
141
+ return err(internal("Database error", e));
142
+ }
143
+ },
144
+ });
@@ -0,0 +1,54 @@
1
+ /**
2
+ * SQL Server token blacklist adapter.
3
+ */
4
+
5
+ import type sql from "mssql";
6
+ import { internal } from "../../../core/errors/app-error.js";
7
+ import type { AppError } from "../../../core/errors/app-error.js";
8
+ import type { TokenBlacklist } from "../../../core/ports/token-blacklist.js";
9
+ import { type Result, err, ok } from "../../../core/types/result.js";
10
+
11
+ export const createMssqlTokenBlacklist = (pool: sql.ConnectionPool): TokenBlacklist => ({
12
+ async add(tokenHash: string, expiresAt: number): Promise<Result<void, AppError>> {
13
+ try {
14
+ await pool
15
+ .request()
16
+ .input("tokenHash", tokenHash)
17
+ .input("expiresAt", expiresAt)
18
+ .input("createdAt", Date.now())
19
+ .query(`
20
+ IF NOT EXISTS (SELECT 1 FROM token_blacklist WHERE token_hash = @tokenHash)
21
+ INSERT INTO token_blacklist (token_hash, expires_at, created_at)
22
+ VALUES (@tokenHash, @expiresAt, @createdAt)
23
+ `);
24
+ return ok(undefined);
25
+ } catch (e: unknown) {
26
+ return err(internal("Database error", e));
27
+ }
28
+ },
29
+
30
+ async isBlacklisted(tokenHash: string): Promise<Result<boolean, AppError>> {
31
+ try {
32
+ const result = await pool
33
+ .request()
34
+ .input("tokenHash", tokenHash)
35
+ .input("now", Date.now())
36
+ .query("SELECT 1 FROM token_blacklist WHERE token_hash = @tokenHash AND expires_at > @now");
37
+ return ok(result.recordset.length > 0);
38
+ } catch (e: unknown) {
39
+ return err(internal("Database error", e));
40
+ }
41
+ },
42
+
43
+ async prune(): Promise<Result<number, AppError>> {
44
+ try {
45
+ const result = await pool
46
+ .request()
47
+ .input("now", Date.now())
48
+ .query("DELETE FROM token_blacklist WHERE expires_at <= @now");
49
+ return ok(result.rowsAffected[0] ?? 0);
50
+ } catch (e: unknown) {
51
+ return err(internal("Database error", e));
52
+ }
53
+ },
54
+ });
@@ -0,0 +1,263 @@
1
+ /**
2
+ * SQL Server user repository — uses the `mssql` npm package.
3
+ *
4
+ * Drop-in replacement for the Postgres/SQLite adapters, implements the same UserRepository port.
5
+ * Uses parameterized queries to prevent SQL injection.
6
+ */
7
+
8
+ import type sql from "mssql";
9
+ import type { User, UserRole } from "../../../core/entities/user.entity.js";
10
+ import { type AppError, conflict, internal, notFound } from "../../../core/errors/app-error.js";
11
+ import type {
12
+ CreateUserData,
13
+ UpdateUserData,
14
+ UserListOptions,
15
+ UserRepository,
16
+ } from "../../../core/ports/user.repository.js";
17
+ import type { UserId } from "../../../core/types/brand.js";
18
+ import { brand } from "../../../core/types/brand.js";
19
+ import { decodeCursor, encodeCursor } from "../../../core/types/pagination.js";
20
+ import { type Result, err, ok } from "../../../core/types/result.js";
21
+ import { generateId } from "../../../shared/utils/id.js";
22
+
23
+ interface UserRow {
24
+ id: string;
25
+ email: string;
26
+ password_hash: string;
27
+ role: string;
28
+ email_verified: boolean;
29
+ mfa_secret: string | null;
30
+ mfa_enabled: boolean;
31
+ password_changed_at: number | null;
32
+ failed_login_attempts: number;
33
+ locked_until: number | null;
34
+ created_at: number;
35
+ updated_at: number;
36
+ }
37
+
38
+ const rowToUser = (row: UserRow): User => ({
39
+ id: brand<string, "UserId">(row.id),
40
+ email: row.email,
41
+ passwordHash: row.password_hash,
42
+ role: row.role as UserRole,
43
+ emailVerified: Boolean(row.email_verified),
44
+ mfaEnabled: Boolean(row.mfa_enabled),
45
+ mfaSecret: row.mfa_secret,
46
+ passwordChangedAt:
47
+ row.password_changed_at !== null ? brand<number, "Timestamp">(row.password_changed_at) : null,
48
+ createdAt: brand<number, "Timestamp">(row.created_at),
49
+ updatedAt: brand<number, "Timestamp">(row.updated_at),
50
+ });
51
+
52
+ const isUniqueViolation = (e: unknown): boolean =>
53
+ e instanceof Error &&
54
+ (e.message.includes("duplicate key") ||
55
+ e.message.includes("unique") ||
56
+ e.message.includes("UNIQUE") ||
57
+ e.message.includes("Violation of UNIQUE KEY"));
58
+
59
+ /** Map UpdateUserData fields to their SQL column names and mssql types */
60
+ const UPDATE_FIELD_MAP: ReadonlyArray<[keyof UpdateUserData, string]> = [
61
+ ["email", "email"],
62
+ ["passwordHash", "password_hash"],
63
+ ["role", "role"],
64
+ ["emailVerified", "email_verified"],
65
+ ["mfaEnabled", "mfa_enabled"],
66
+ ["mfaSecret", "mfa_secret"],
67
+ ["passwordChangedAt", "password_changed_at"],
68
+ ];
69
+
70
+ export const createMssqlUserRepository = (pool: sql.ConnectionPool): UserRepository => {
71
+ return {
72
+ async findById(id: UserId): Promise<Result<User, AppError>> {
73
+ try {
74
+ const result = await pool
75
+ .request()
76
+ .input("id", id as string)
77
+ .query("SELECT * FROM users WHERE id = @id");
78
+ if (result.recordset.length === 0) return err(notFound("User"));
79
+ return ok(rowToUser(result.recordset[0] as UserRow));
80
+ } catch (e: unknown) {
81
+ return err(internal("Database error", e));
82
+ }
83
+ },
84
+
85
+ async findByEmail(email: string): Promise<Result<User, AppError>> {
86
+ try {
87
+ const result = await pool
88
+ .request()
89
+ .input("email", email)
90
+ .query("SELECT * FROM users WHERE email = @email");
91
+ if (result.recordset.length === 0) return err(notFound("User"));
92
+ return ok(rowToUser(result.recordset[0] as UserRow));
93
+ } catch (e: unknown) {
94
+ return err(internal("Database error", e));
95
+ }
96
+ },
97
+
98
+ async create(data: CreateUserData): Promise<Result<User, AppError>> {
99
+ try {
100
+ const id = generateId();
101
+ const now = Date.now();
102
+
103
+ try {
104
+ await pool
105
+ .request()
106
+ .input("id", id)
107
+ .input("email", data.email)
108
+ .input("passwordHash", data.passwordHash)
109
+ .input("role", data.role)
110
+ .input("now", now)
111
+ .query(`
112
+ INSERT INTO users (id, email, password_hash, role, email_verified, mfa_enabled, failed_login_attempts, created_at, updated_at)
113
+ VALUES (@id, @email, @passwordHash, @role, 0, 0, 0, @now, @now)
114
+ `);
115
+ } catch (e: unknown) {
116
+ if (isUniqueViolation(e)) return err(conflict("Email already exists"));
117
+ throw e;
118
+ }
119
+
120
+ const user: User = {
121
+ id: brand<string, "UserId">(id),
122
+ email: data.email,
123
+ passwordHash: data.passwordHash,
124
+ role: data.role,
125
+ emailVerified: false,
126
+ mfaEnabled: false,
127
+ mfaSecret: null,
128
+ passwordChangedAt: null,
129
+ createdAt: brand<number, "Timestamp">(now),
130
+ updatedAt: brand<number, "Timestamp">(now),
131
+ };
132
+
133
+ return ok(user);
134
+ } catch (e: unknown) {
135
+ return err(internal("Database error", e));
136
+ }
137
+ },
138
+
139
+ // biome-ignore lint/complexity/noExcessiveCognitiveComplexity: SQL builder with dynamic SET clauses needs branching
140
+ async update(id: UserId, data: UpdateUserData): Promise<Result<User, AppError>> {
141
+ try {
142
+ // Check existence first
143
+ const existing = await pool
144
+ .request()
145
+ .input("id", id as string)
146
+ .query("SELECT id FROM users WHERE id = @id");
147
+ if (existing.recordset.length === 0) return err(notFound("User"));
148
+
149
+ // Build dynamic SET clause
150
+ const sets: string[] = [];
151
+ const req = pool.request();
152
+ let paramIdx = 0;
153
+
154
+ for (const [field, column] of UPDATE_FIELD_MAP) {
155
+ if (data[field] !== undefined) {
156
+ const paramName = `p${paramIdx++}`;
157
+ sets.push(`${column} = @${paramName}`);
158
+ req.input(paramName, data[field] as string | number | boolean | null);
159
+ }
160
+ }
161
+
162
+ const updatedAtParam = `p${paramIdx}`;
163
+ sets.push(`updated_at = @${updatedAtParam}`);
164
+ req.input(updatedAtParam, Date.now());
165
+ req.input("updateId", id as string);
166
+
167
+ try {
168
+ await req.query(`UPDATE users SET ${sets.join(", ")} WHERE id = @updateId`);
169
+ } catch (e: unknown) {
170
+ if (isUniqueViolation(e)) return err(conflict("Email already exists"));
171
+ throw e;
172
+ }
173
+
174
+ const updated = await pool
175
+ .request()
176
+ .input("id", id as string)
177
+ .query("SELECT * FROM users WHERE id = @id");
178
+ if (updated.recordset.length === 0) return err(internal("User disappeared after update"));
179
+ return ok(rowToUser(updated.recordset[0] as UserRow));
180
+ } catch (e: unknown) {
181
+ if (isUniqueViolation(e)) return err(conflict("Email already exists"));
182
+ return err(internal("Database error", e));
183
+ }
184
+ },
185
+
186
+ async delete(id: UserId): Promise<Result<void, AppError>> {
187
+ try {
188
+ const existing = await pool
189
+ .request()
190
+ .input("id", id as string)
191
+ .query("SELECT id FROM users WHERE id = @id");
192
+ if (existing.recordset.length === 0) return err(notFound("User"));
193
+
194
+ await pool
195
+ .request()
196
+ .input("id", id as string)
197
+ .query("DELETE FROM users WHERE id = @id");
198
+ return ok(undefined);
199
+ } catch (e: unknown) {
200
+ return err(internal("Database error", e));
201
+ }
202
+ },
203
+
204
+ // biome-ignore lint/complexity/noExcessiveCognitiveComplexity: SQL builder with cursor/filter/search needs branching
205
+ async list(options: UserListOptions) {
206
+ try {
207
+ const conditions: string[] = [];
208
+ const req = pool.request();
209
+ let paramIdx = 0;
210
+
211
+ if (options.cursor !== undefined) {
212
+ const decoded = decodeCursor(options.cursor);
213
+ if (decoded !== null) {
214
+ const paramName = `p${paramIdx++}`;
215
+ conditions.push(`created_at < @${paramName}`);
216
+ req.input(paramName, Number(decoded));
217
+ }
218
+ }
219
+
220
+ if (options.role !== undefined) {
221
+ const paramName = `p${paramIdx++}`;
222
+ conditions.push(`role = @${paramName}`);
223
+ req.input(paramName, options.role);
224
+ }
225
+
226
+ if (options.search !== undefined) {
227
+ const paramName = `p${paramIdx++}`;
228
+ conditions.push(`email LIKE @${paramName}`);
229
+ req.input(paramName, `%${options.search}%`);
230
+ }
231
+
232
+ const where = conditions.length > 0 ? `WHERE ${conditions.join(" AND ")}` : "";
233
+ const limit = Math.min(options.limit, 100);
234
+ const limitParam = `p${paramIdx}`;
235
+ req.input(limitParam, limit + 1);
236
+
237
+ const query = `SELECT TOP (@${limitParam}) * FROM users ${where} ORDER BY created_at DESC`;
238
+ const result = await req.query(query);
239
+ const rows = result.recordset as UserRow[];
240
+
241
+ const hasMore = rows.length > limit;
242
+ const items = (hasMore ? rows.slice(0, limit) : rows).map(rowToUser);
243
+
244
+ const lastItem = items[items.length - 1];
245
+ const nextCursor =
246
+ hasMore && lastItem !== undefined ? encodeCursor(String(lastItem.createdAt)) : null;
247
+
248
+ return ok({ items, nextCursor, hasMore });
249
+ } catch (e: unknown) {
250
+ return err(internal("Database error", e));
251
+ }
252
+ },
253
+
254
+ async count() {
255
+ try {
256
+ const result = await pool.request().query("SELECT COUNT(*) as cnt FROM users");
257
+ return ok(Number(result.recordset[0].cnt));
258
+ } catch (e: unknown) {
259
+ return err(internal("Database error", e));
260
+ }
261
+ },
262
+ };
263
+ };