@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,299 @@
1
+ /**
2
+ * SQL Server migration runner — uses the `mssql` npm package.
3
+ *
4
+ * Mirrors the Postgres migration runner pattern but uses T-SQL DDL.
5
+ * Migrations are idempotent and wrapped in transactions.
6
+ */
7
+
8
+ import type sql from "mssql";
9
+ import type { Logger } from "../../../core/ports/logger.js";
10
+
11
+ interface MssqlMigration {
12
+ readonly version: string;
13
+ readonly name: string;
14
+ readonly up: string; // T-SQL DDL
15
+ readonly down: string;
16
+ }
17
+
18
+ /**
19
+ * All SQL Server migrations — inlined T-SQL strings.
20
+ * Uses NVARCHAR for text, BIGINT for timestamps (ms), BIT for booleans.
21
+ */
22
+ const migrations: readonly MssqlMigration[] = [
23
+ {
24
+ version: "001",
25
+ name: "create_users",
26
+ up: `
27
+ IF NOT EXISTS (SELECT * FROM sys.tables WHERE name = 'users')
28
+ CREATE TABLE users (
29
+ id NVARCHAR(36) NOT NULL PRIMARY KEY,
30
+ email NVARCHAR(255) NOT NULL UNIQUE,
31
+ password_hash NVARCHAR(255) NOT NULL,
32
+ role NVARCHAR(20) NOT NULL DEFAULT 'user',
33
+ email_verified BIT NOT NULL DEFAULT 0,
34
+ mfa_secret NVARCHAR(255) NULL,
35
+ mfa_enabled BIT NOT NULL DEFAULT 0,
36
+ password_changed_at BIGINT NULL,
37
+ failed_login_attempts INT NOT NULL DEFAULT 0,
38
+ locked_until BIGINT NULL,
39
+ created_at BIGINT NOT NULL,
40
+ updated_at BIGINT NOT NULL
41
+ );
42
+
43
+ IF NOT EXISTS (SELECT * FROM sys.indexes WHERE name = 'idx_users_email')
44
+ CREATE UNIQUE INDEX idx_users_email ON users(email);
45
+ `,
46
+ down: `
47
+ DROP INDEX IF EXISTS idx_users_email ON users;
48
+ IF EXISTS (SELECT * FROM sys.tables WHERE name = 'users')
49
+ DROP TABLE users;
50
+ `,
51
+ },
52
+ {
53
+ version: "002",
54
+ name: "create_token_blacklist",
55
+ up: `
56
+ IF NOT EXISTS (SELECT * FROM sys.tables WHERE name = 'token_blacklist')
57
+ CREATE TABLE token_blacklist (
58
+ token_hash NVARCHAR(128) NOT NULL PRIMARY KEY,
59
+ expires_at BIGINT NOT NULL,
60
+ created_at BIGINT NOT NULL
61
+ );
62
+
63
+ IF NOT EXISTS (SELECT * FROM sys.indexes WHERE name = 'idx_token_blacklist_expires')
64
+ CREATE INDEX idx_token_blacklist_expires ON token_blacklist(expires_at);
65
+ `,
66
+ down: `
67
+ DROP INDEX IF EXISTS idx_token_blacklist_expires ON token_blacklist;
68
+ IF EXISTS (SELECT * FROM sys.tables WHERE name = 'token_blacklist')
69
+ DROP TABLE token_blacklist;
70
+ `,
71
+ },
72
+ {
73
+ version: "003",
74
+ name: "create_audit_log",
75
+ up: `
76
+ IF NOT EXISTS (SELECT * FROM sys.tables WHERE name = 'audit_log')
77
+ CREATE TABLE audit_log (
78
+ id NVARCHAR(36) NOT NULL PRIMARY KEY,
79
+ user_id NVARCHAR(36) NULL,
80
+ action NVARCHAR(50) NOT NULL,
81
+ resource NVARCHAR(100) NOT NULL,
82
+ resource_id NVARCHAR(36) NULL,
83
+ detail NVARCHAR(MAX) NULL,
84
+ ip NVARCHAR(45) NOT NULL,
85
+ timestamp BIGINT NOT NULL
86
+ );
87
+
88
+ IF NOT EXISTS (SELECT * FROM sys.indexes WHERE name = 'idx_audit_log_user')
89
+ CREATE INDEX idx_audit_log_user ON audit_log(user_id);
90
+
91
+ IF NOT EXISTS (SELECT * FROM sys.indexes WHERE name = 'idx_audit_log_action')
92
+ CREATE INDEX idx_audit_log_action ON audit_log(action);
93
+
94
+ IF NOT EXISTS (SELECT * FROM sys.indexes WHERE name = 'idx_audit_log_timestamp')
95
+ CREATE INDEX idx_audit_log_timestamp ON audit_log(timestamp);
96
+ `,
97
+ down: `
98
+ DROP INDEX IF EXISTS idx_audit_log_timestamp ON audit_log;
99
+ DROP INDEX IF EXISTS idx_audit_log_action ON audit_log;
100
+ DROP INDEX IF EXISTS idx_audit_log_user ON audit_log;
101
+ IF EXISTS (SELECT * FROM sys.tables WHERE name = 'audit_log')
102
+ DROP TABLE audit_log;
103
+ `,
104
+ },
105
+ {
106
+ version: "004",
107
+ name: "auth_platform",
108
+ up: `
109
+ IF NOT EXISTS (SELECT * FROM sys.tables WHERE name = 'verification_tokens')
110
+ CREATE TABLE verification_tokens (
111
+ id NVARCHAR(36) NOT NULL PRIMARY KEY,
112
+ user_id NVARCHAR(36) NOT NULL REFERENCES users(id) ON DELETE CASCADE,
113
+ type NVARCHAR(30) NOT NULL CHECK (type IN ('email_verification', 'password_reset')),
114
+ token_hash NVARCHAR(128) NOT NULL UNIQUE,
115
+ expires_at BIGINT NOT NULL,
116
+ used_at BIGINT NULL,
117
+ created_at BIGINT NOT NULL
118
+ );
119
+
120
+ IF NOT EXISTS (SELECT * FROM sys.indexes WHERE name = 'idx_verification_tokens_hash')
121
+ CREATE INDEX idx_verification_tokens_hash ON verification_tokens(token_hash);
122
+
123
+ IF NOT EXISTS (SELECT * FROM sys.indexes WHERE name = 'idx_verification_tokens_user')
124
+ CREATE INDEX idx_verification_tokens_user ON verification_tokens(user_id, type);
125
+
126
+ IF NOT EXISTS (SELECT * FROM sys.tables WHERE name = 'refresh_token_families')
127
+ CREATE TABLE refresh_token_families (
128
+ id NVARCHAR(36) NOT NULL PRIMARY KEY,
129
+ user_id NVARCHAR(36) NOT NULL REFERENCES users(id) ON DELETE CASCADE,
130
+ current_token_hash NVARCHAR(128) NOT NULL,
131
+ revoked BIT NOT NULL DEFAULT 0,
132
+ created_at BIGINT NOT NULL,
133
+ updated_at BIGINT NOT NULL
134
+ );
135
+
136
+ IF NOT EXISTS (SELECT * FROM sys.indexes WHERE name = 'idx_refresh_families_user')
137
+ CREATE INDEX idx_refresh_families_user ON refresh_token_families(user_id);
138
+
139
+ IF NOT EXISTS (SELECT * FROM sys.indexes WHERE name = 'idx_refresh_families_token')
140
+ CREATE INDEX idx_refresh_families_token ON refresh_token_families(current_token_hash);
141
+
142
+ IF NOT EXISTS (SELECT * FROM sys.tables WHERE name = 'api_keys')
143
+ CREATE TABLE api_keys (
144
+ id NVARCHAR(36) NOT NULL PRIMARY KEY,
145
+ user_id NVARCHAR(36) NOT NULL REFERENCES users(id) ON DELETE CASCADE,
146
+ name NVARCHAR(100) NOT NULL,
147
+ key_hash NVARCHAR(128) NOT NULL UNIQUE,
148
+ key_prefix NVARCHAR(20) NOT NULL,
149
+ scopes NVARCHAR(MAX) NOT NULL DEFAULT '[]',
150
+ expires_at BIGINT NULL,
151
+ last_used_at BIGINT NULL,
152
+ created_at BIGINT NOT NULL
153
+ );
154
+
155
+ IF NOT EXISTS (SELECT * FROM sys.indexes WHERE name = 'idx_api_keys_hash')
156
+ CREATE INDEX idx_api_keys_hash ON api_keys(key_hash);
157
+
158
+ IF NOT EXISTS (SELECT * FROM sys.indexes WHERE name = 'idx_api_keys_user')
159
+ CREATE INDEX idx_api_keys_user ON api_keys(user_id);
160
+
161
+ IF NOT EXISTS (SELECT * FROM sys.tables WHERE name = 'password_history')
162
+ CREATE TABLE password_history (
163
+ id NVARCHAR(36) NOT NULL PRIMARY KEY,
164
+ user_id NVARCHAR(36) NOT NULL REFERENCES users(id) ON DELETE CASCADE,
165
+ password_hash NVARCHAR(255) NOT NULL,
166
+ created_at BIGINT NOT NULL
167
+ );
168
+
169
+ IF NOT EXISTS (SELECT * FROM sys.indexes WHERE name = 'idx_password_history_user')
170
+ CREATE INDEX idx_password_history_user ON password_history(user_id);
171
+
172
+ IF NOT EXISTS (SELECT * FROM sys.tables WHERE name = 'oauth_accounts')
173
+ CREATE TABLE oauth_accounts (
174
+ id NVARCHAR(36) NOT NULL PRIMARY KEY,
175
+ user_id NVARCHAR(36) NOT NULL REFERENCES users(id) ON DELETE CASCADE,
176
+ provider NVARCHAR(30) NOT NULL,
177
+ provider_user_id NVARCHAR(255) NOT NULL,
178
+ email NVARCHAR(255) NULL,
179
+ created_at BIGINT NOT NULL,
180
+ UNIQUE(provider, provider_user_id)
181
+ );
182
+
183
+ IF NOT EXISTS (SELECT * FROM sys.indexes WHERE name = 'idx_oauth_accounts_user')
184
+ CREATE INDEX idx_oauth_accounts_user ON oauth_accounts(user_id);
185
+
186
+ IF NOT EXISTS (SELECT * FROM sys.indexes WHERE name = 'idx_oauth_accounts_provider')
187
+ CREATE INDEX idx_oauth_accounts_provider ON oauth_accounts(provider, provider_user_id);
188
+ `,
189
+ down: `
190
+ IF EXISTS (SELECT * FROM sys.tables WHERE name = 'oauth_accounts') DROP TABLE oauth_accounts;
191
+ IF EXISTS (SELECT * FROM sys.tables WHERE name = 'password_history') DROP TABLE password_history;
192
+ IF EXISTS (SELECT * FROM sys.tables WHERE name = 'api_keys') DROP TABLE api_keys;
193
+ IF EXISTS (SELECT * FROM sys.tables WHERE name = 'refresh_token_families') DROP TABLE refresh_token_families;
194
+ IF EXISTS (SELECT * FROM sys.tables WHERE name = 'verification_tokens') DROP TABLE verification_tokens;
195
+ `,
196
+ },
197
+ ];
198
+
199
+ const ensureMigrationsTable = async (pool: sql.ConnectionPool): Promise<void> => {
200
+ await pool.request().query(`
201
+ IF NOT EXISTS (SELECT * FROM sys.tables WHERE name = '_migrations')
202
+ CREATE TABLE _migrations (
203
+ version NVARCHAR(10) NOT NULL PRIMARY KEY,
204
+ name NVARCHAR(100) NOT NULL,
205
+ applied_at BIGINT NOT NULL
206
+ )
207
+ `);
208
+ };
209
+
210
+ const getAppliedVersions = async (pool: sql.ConnectionPool): Promise<Set<string>> => {
211
+ const result = await pool.request().query("SELECT version FROM _migrations ORDER BY version");
212
+ return new Set(result.recordset.map((r: { version: string }) => r.version));
213
+ };
214
+
215
+ export const mssqlMigrateUp = async (pool: sql.ConnectionPool, logger: Logger): Promise<number> => {
216
+ await ensureMigrationsTable(pool);
217
+ const applied = await getAppliedVersions(pool);
218
+ let count = 0;
219
+
220
+ for (const migration of migrations) {
221
+ if (applied.has(migration.version)) continue;
222
+
223
+ const tx = pool.transaction();
224
+ await tx.begin();
225
+
226
+ try {
227
+ // T-SQL: execute each batch separated by GO-like blocks (we split on ;; for multi-statement)
228
+ // Since our DDL uses IF NOT EXISTS guards, we run each statement individually
229
+ for (const stmt of migration.up.split(";").filter((s: string) => s.trim())) {
230
+ await tx.request().query(stmt.trim());
231
+ }
232
+
233
+ await tx
234
+ .request()
235
+ .input("version", migration.version)
236
+ .input("name", migration.name)
237
+ .input("appliedAt", Date.now())
238
+ .query(
239
+ "INSERT INTO _migrations (version, name, applied_at) VALUES (@version, @name, @appliedAt)",
240
+ );
241
+
242
+ await tx.commit();
243
+ } catch (e) {
244
+ await tx.rollback();
245
+ throw e;
246
+ }
247
+
248
+ logger.info("Migration applied", {
249
+ version: migration.version,
250
+ name: migration.name,
251
+ });
252
+ count++;
253
+ }
254
+
255
+ if (count > 0) {
256
+ logger.info("SQL Server migrations complete", { applied: count });
257
+ }
258
+ return count;
259
+ };
260
+
261
+ export const mssqlMigrateDown = async (
262
+ pool: sql.ConnectionPool,
263
+ logger: Logger,
264
+ ): Promise<string | null> => {
265
+ await ensureMigrationsTable(pool);
266
+ const applied = await getAppliedVersions(pool);
267
+ const reversed = [...migrations].reverse();
268
+
269
+ for (const migration of reversed) {
270
+ if (!applied.has(migration.version)) continue;
271
+
272
+ const tx = pool.transaction();
273
+ await tx.begin();
274
+
275
+ try {
276
+ for (const stmt of migration.down.split(";").filter((s: string) => s.trim())) {
277
+ await tx.request().query(stmt.trim());
278
+ }
279
+
280
+ await tx
281
+ .request()
282
+ .input("version", migration.version)
283
+ .query("DELETE FROM _migrations WHERE version = @version");
284
+
285
+ await tx.commit();
286
+ } catch (e) {
287
+ await tx.rollback();
288
+ throw e;
289
+ }
290
+
291
+ logger.info("Migration rolled back", {
292
+ version: migration.version,
293
+ name: migration.name,
294
+ });
295
+ return migration.version;
296
+ }
297
+
298
+ return null;
299
+ };
@@ -0,0 +1,95 @@
1
+ /**
2
+ * SQL Server account lockout 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 { AccountLockout } from "../../../core/ports/account-lockout.js";
9
+ import { type Result, err, ok } from "../../../core/types/result.js";
10
+
11
+ interface LockoutOptions {
12
+ readonly maxAttempts: number;
13
+ readonly lockoutDurationMs: number;
14
+ }
15
+
16
+ export const createMssqlAccountLockout = (
17
+ pool: sql.ConnectionPool,
18
+ options: LockoutOptions,
19
+ ): AccountLockout => ({
20
+ async recordFailedAttempt(email: string): Promise<Result<boolean, AppError>> {
21
+ try {
22
+ await pool
23
+ .request()
24
+ .input("email", email)
25
+ .query(
26
+ "UPDATE users SET failed_login_attempts = failed_login_attempts + 1 WHERE email = @email",
27
+ );
28
+
29
+ const result = await pool
30
+ .request()
31
+ .input("email", email)
32
+ .query("SELECT failed_login_attempts FROM users WHERE email = @email");
33
+
34
+ if (result.recordset.length === 0) return ok(false);
35
+
36
+ const attempts = Number(result.recordset[0].failed_login_attempts);
37
+ if (attempts >= options.maxAttempts) {
38
+ const lockUntil = Date.now() + options.lockoutDurationMs;
39
+ await pool
40
+ .request()
41
+ .input("lockUntil", lockUntil)
42
+ .input("attempts", attempts)
43
+ .input("email", email)
44
+ .query(
45
+ "UPDATE users SET locked_until = @lockUntil, failed_login_attempts = @attempts WHERE email = @email",
46
+ );
47
+ return ok(true);
48
+ }
49
+ return ok(false);
50
+ } catch (e: unknown) {
51
+ return err(internal("Database error", e));
52
+ }
53
+ },
54
+
55
+ async resetAttempts(email: string): Promise<Result<void, AppError>> {
56
+ try {
57
+ await pool
58
+ .request()
59
+ .input("email", email)
60
+ .query(
61
+ "UPDATE users SET failed_login_attempts = 0, locked_until = NULL WHERE email = @email",
62
+ );
63
+ return ok(undefined);
64
+ } catch (e: unknown) {
65
+ return err(internal("Database error", e));
66
+ }
67
+ },
68
+
69
+ async isLocked(email: string): Promise<Result<number | null, AppError>> {
70
+ try {
71
+ const result = await pool
72
+ .request()
73
+ .input("email", email)
74
+ .query("SELECT locked_until FROM users WHERE email = @email");
75
+
76
+ if (result.recordset.length === 0) return ok(null);
77
+
78
+ const lockedUntil = result.recordset[0].locked_until;
79
+ if (lockedUntil === null || lockedUntil === undefined) return ok(null);
80
+ if (Number(lockedUntil) <= Date.now()) {
81
+ // Lock expired — clear it
82
+ await pool
83
+ .request()
84
+ .input("email", email)
85
+ .query(
86
+ "UPDATE users SET locked_until = NULL, failed_login_attempts = 0 WHERE email = @email",
87
+ );
88
+ return ok(null);
89
+ }
90
+ return ok(Number(lockedUntil));
91
+ } catch (e: unknown) {
92
+ return err(internal("Database error", e));
93
+ }
94
+ },
95
+ });
@@ -0,0 +1,146 @@
1
+ /**
2
+ * SQL Server API key repository adapter.
3
+ */
4
+
5
+ import type sql from "mssql";
6
+ import { internal, notFound } from "../../../core/errors/app-error.js";
7
+ import type { AppError } from "../../../core/errors/app-error.js";
8
+ import type { ApiKey, ApiKeyRepository } from "../../../core/ports/api-key.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 KEY_PREFIX = "oapi_";
15
+
16
+ const rowToApiKey = (row: Record<string, unknown>): ApiKey => ({
17
+ id: row["id"] as string,
18
+ userId: brand<string, "UserId">(row["user_id"] as string),
19
+ name: row["name"] as string,
20
+ keyPrefix: row["key_prefix"] as string,
21
+ scopes: JSON.parse((row["scopes"] as string) || "[]") as readonly string[],
22
+ expiresAt: row["expires_at"] !== null ? Number(row["expires_at"]) : null,
23
+ lastUsedAt: row["last_used_at"] !== null ? Number(row["last_used_at"]) : null,
24
+ createdAt: Number(row["created_at"]),
25
+ });
26
+
27
+ export const createMssqlApiKeyRepository = (pool: sql.ConnectionPool): ApiKeyRepository => ({
28
+ async create(
29
+ userId: UserId,
30
+ name: string,
31
+ scopes: readonly string[],
32
+ expiresAt?: number,
33
+ ): Promise<Result<{ key: ApiKey; rawKey: string }, AppError>> {
34
+ try {
35
+ const id = generateId();
36
+ const rawKey = `${KEY_PREFIX}${generateId()}${generateId()}`;
37
+ const prefix = rawKey.substring(0, KEY_PREFIX.length + 8);
38
+
39
+ const encoder = new TextEncoder();
40
+ const hashBuffer = await crypto.subtle.digest("SHA-256", encoder.encode(rawKey));
41
+ const keyHash = [...new Uint8Array(hashBuffer)]
42
+ .map((b) => b.toString(16).padStart(2, "0"))
43
+ .join("");
44
+
45
+ const now = Date.now();
46
+
47
+ await pool
48
+ .request()
49
+ .input("id", id)
50
+ .input("userId", userId as string)
51
+ .input("name", name)
52
+ .input("keyHash", keyHash)
53
+ .input("keyPrefix", prefix)
54
+ .input("scopes", JSON.stringify(scopes))
55
+ .input("expiresAt", expiresAt ?? null)
56
+ .input("createdAt", now)
57
+ .query(`
58
+ INSERT INTO api_keys (id, user_id, name, key_hash, key_prefix, scopes, expires_at, created_at)
59
+ VALUES (@id, @userId, @name, @keyHash, @keyPrefix, @scopes, @expiresAt, @createdAt)
60
+ `);
61
+
62
+ const key: ApiKey = {
63
+ id,
64
+ userId,
65
+ name,
66
+ keyPrefix: prefix,
67
+ scopes,
68
+ expiresAt: expiresAt ?? null,
69
+ lastUsedAt: null,
70
+ createdAt: now,
71
+ };
72
+
73
+ return ok({ key, rawKey });
74
+ } catch (e: unknown) {
75
+ return err(internal("Database error", e));
76
+ }
77
+ },
78
+
79
+ async verify(rawKey: string): Promise<Result<ApiKey, AppError>> {
80
+ try {
81
+ const encoder = new TextEncoder();
82
+ const hashBuffer = await crypto.subtle.digest("SHA-256", encoder.encode(rawKey));
83
+ const keyHash = [...new Uint8Array(hashBuffer)]
84
+ .map((b) => b.toString(16).padStart(2, "0"))
85
+ .join("");
86
+
87
+ const result = await pool
88
+ .request()
89
+ .input("keyHash", keyHash)
90
+ .query("SELECT * FROM api_keys WHERE key_hash = @keyHash");
91
+
92
+ if (result.recordset.length === 0) return err(notFound("API key"));
93
+
94
+ const key = rowToApiKey(result.recordset[0] as Record<string, unknown>);
95
+ if (key.expiresAt !== null && key.expiresAt < Date.now()) {
96
+ return err(notFound("API key"));
97
+ }
98
+
99
+ return ok(key);
100
+ } catch (e: unknown) {
101
+ return err(internal("Database error", e));
102
+ }
103
+ },
104
+
105
+ async listByUser(userId: UserId): Promise<Result<readonly ApiKey[], AppError>> {
106
+ try {
107
+ const result = await pool
108
+ .request()
109
+ .input("userId", userId as string)
110
+ .query("SELECT * FROM api_keys WHERE user_id = @userId ORDER BY created_at DESC");
111
+ return ok(result.recordset.map((r: Record<string, unknown>) => rowToApiKey(r)));
112
+ } catch (e: unknown) {
113
+ return err(internal("Database error", e));
114
+ }
115
+ },
116
+
117
+ async revoke(id: string, userId: UserId): Promise<Result<void, AppError>> {
118
+ try {
119
+ const result = await pool
120
+ .request()
121
+ .input("id", id)
122
+ .input("userId", userId as string)
123
+ .query("SELECT id FROM api_keys WHERE id = @id AND user_id = @userId");
124
+
125
+ if (result.recordset.length === 0) return err(notFound("API key"));
126
+
127
+ await pool.request().input("id", id).query("DELETE FROM api_keys WHERE id = @id");
128
+ return ok(undefined);
129
+ } catch (e: unknown) {
130
+ return err(internal("Database error", e));
131
+ }
132
+ },
133
+
134
+ async touch(id: string): Promise<Result<void, AppError>> {
135
+ try {
136
+ await pool
137
+ .request()
138
+ .input("lastUsedAt", Date.now())
139
+ .input("id", id)
140
+ .query("UPDATE api_keys SET last_used_at = @lastUsedAt WHERE id = @id");
141
+ return ok(undefined);
142
+ } catch (e: unknown) {
143
+ return err(internal("Database error", e));
144
+ }
145
+ },
146
+ });
@@ -0,0 +1,86 @@
1
+ /**
2
+ * SQL Server audit log 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 { AuditEntry, AuditLog, AuditQueryOptions } from "../../../core/ports/audit-log.js";
9
+ import { type Result, err, ok } from "../../../core/types/result.js";
10
+ import { generateId } from "../../../shared/utils/id.js";
11
+
12
+ export const createMssqlAuditLog = (pool: sql.ConnectionPool): AuditLog => ({
13
+ async append(entry: Omit<AuditEntry, "id" | "timestamp">): Promise<Result<void, AppError>> {
14
+ try {
15
+ const id = generateId();
16
+ const timestamp = Date.now();
17
+ await pool
18
+ .request()
19
+ .input("id", id)
20
+ .input("userId", entry.userId)
21
+ .input("action", entry.action)
22
+ .input("resource", entry.resource)
23
+ .input("resourceId", entry.resourceId)
24
+ .input("detail", entry.detail)
25
+ .input("ip", entry.ip)
26
+ .input("timestamp", timestamp)
27
+ .query(`
28
+ INSERT INTO audit_log (id, user_id, action, resource, resource_id, detail, ip, timestamp)
29
+ VALUES (@id, @userId, @action, @resource, @resourceId, @detail, @ip, @timestamp)
30
+ `);
31
+ return ok(undefined);
32
+ } catch (e: unknown) {
33
+ return err(internal("Database error", e));
34
+ }
35
+ },
36
+
37
+ async query(options: AuditQueryOptions): Promise<Result<readonly AuditEntry[], AppError>> {
38
+ try {
39
+ const conditions: string[] = [];
40
+ const req = pool.request();
41
+ let paramIdx = 0;
42
+
43
+ if (options.userId !== undefined) {
44
+ const p = `p${paramIdx++}`;
45
+ conditions.push(`user_id = @${p}`);
46
+ req.input(p, options.userId);
47
+ }
48
+ if (options.action !== undefined) {
49
+ const p = `p${paramIdx++}`;
50
+ conditions.push(`action = @${p}`);
51
+ req.input(p, options.action);
52
+ }
53
+ if (options.since !== undefined) {
54
+ const p = `p${paramIdx++}`;
55
+ conditions.push(`timestamp >= @${p}`);
56
+ req.input(p, options.since);
57
+ }
58
+
59
+ const where = conditions.length > 0 ? `WHERE ${conditions.join(" AND ")}` : "";
60
+ const limit = options.limit ?? 50;
61
+ const limitParam = `p${paramIdx}`;
62
+ req.input(limitParam, limit);
63
+
64
+ const query = `SELECT TOP (@${limitParam}) * FROM audit_log ${where} ORDER BY timestamp DESC`;
65
+ const result = await req.query(query);
66
+
67
+ return ok(
68
+ result.recordset.map(
69
+ (r: Record<string, unknown>) =>
70
+ ({
71
+ id: r["id"] as string,
72
+ userId: (r["user_id"] as string) ?? null,
73
+ action: r["action"],
74
+ resource: r["resource"] as string,
75
+ resourceId: (r["resource_id"] as string) ?? null,
76
+ detail: (r["detail"] as string) ?? null,
77
+ ip: r["ip"] as string,
78
+ timestamp: Number(r["timestamp"]),
79
+ }) as AuditEntry,
80
+ ),
81
+ );
82
+ } catch (e: unknown) {
83
+ return err(internal("Database error", e));
84
+ }
85
+ },
86
+ });